"Fossies" - the Fresh Open Source Software Archive

Member "neos-development-collection-7.0.1/Neos.Neos/Classes/Routing/FrontendNodeRoutePartHandler.php" (23 Feb 2021, 37739 Bytes) of package /linux/www/neos-development-collection-7.0.1.tar.gz:


As a special service "Fossies" has tried to format the requested source page into HTML format using (guessed) PHP source code syntax highlighting (style: standard) with prefixed line numbers and code folding option. Alternatively you can here view or download the uninterpreted source code file. For more information about "FrontendNodeRoutePartHandler.php" see the Fossies "Dox" file reference documentation and the latest Fossies "Diffs" side-by-side code changes report: 7.0.0_vs_7.0.1.

    1 <?php
    2 namespace Neos\Neos\Routing;
    3 
    4 /*
    5  * This file is part of the Neos.Neos package.
    6  *
    7  * (c) Contributors of the Neos Project - www.neos.io
    8  *
    9  * This package is Open Source Software. For the full copyright and license
   10  * information, please view the LICENSE file which was distributed with this
   11  * source code.
   12  */
   13 
   14 use GuzzleHttp\Psr7\Uri;
   15 use Neos\ContentRepository\Domain\Model\NodeInterface;
   16 use Neos\ContentRepository\Domain\Utility\NodePaths;
   17 use Neos\Flow\Annotations as Flow;
   18 use Neos\Flow\Mvc\Routing\Dto\MatchResult;
   19 use Neos\Flow\Mvc\Routing\Dto\ResolveResult;
   20 use Neos\Flow\Mvc\Routing\Dto\UriConstraints;
   21 use Neos\Flow\Mvc\Routing\DynamicRoutePart;
   22 use Neos\Flow\Persistence\Exception\IllegalObjectTypeException;
   23 use Neos\Flow\Security\Context;
   24 use Neos\Neos\Domain\Model\Domain;
   25 use Neos\Neos\Domain\Model\Site;
   26 use Neos\Neos\Domain\Repository\DomainRepository;
   27 use Neos\Neos\Domain\Repository\SiteRepository;
   28 use Neos\Neos\Domain\Service\ContentContext;
   29 use Neos\Neos\Domain\Service\ContentContextFactory;
   30 use Neos\Neos\Domain\Service\ContentDimensionPresetSourceInterface;
   31 use Neos\Neos\Domain\Service\NodeShortcutResolver;
   32 use Neos\Neos\Domain\Service\SiteService;
   33 use Neos\Neos\Exception as NeosException;
   34 use Neos\Neos\Routing\Exception\InvalidDimensionPresetCombinationException;
   35 use Neos\Neos\Routing\Exception\InvalidRequestPathException;
   36 use Neos\Neos\Routing\Exception\NoSuchDimensionValueException;
   37 use Psr\Http\Message\UriInterface;
   38 use Psr\Log\LoggerInterface;
   39 
   40 /**
   41  * A route part handler for finding nodes specifically in the website's frontend.
   42  */
   43 class FrontendNodeRoutePartHandler extends DynamicRoutePart implements FrontendNodeRoutePartHandlerInterface
   44 {
   45 
   46     /**
   47      * @Flow\Inject
   48      * @var LoggerInterface
   49      */
   50     protected $systemLogger;
   51 
   52     /**
   53      * @Flow\Inject
   54      * @var ContentContextFactory
   55      */
   56     protected $contextFactory;
   57 
   58     /**
   59      * @Flow\Inject
   60      * @var Context
   61      */
   62     protected $securityContext;
   63 
   64     /**
   65      * @Flow\Inject
   66      * @var DomainRepository
   67      */
   68     protected $domainRepository;
   69 
   70     /**
   71      * @Flow\Inject
   72      * @var SiteRepository
   73      */
   74     protected $siteRepository;
   75 
   76     /**
   77      * @Flow\InjectConfiguration("routing.supportEmptySegmentForDimensions", package="Neos.Neos")
   78      * @var boolean
   79      */
   80     protected $supportEmptySegmentForDimensions;
   81 
   82     /**
   83      * @Flow\Inject
   84      * @var ContentDimensionPresetSourceInterface
   85      */
   86     protected $contentDimensionPresetSource;
   87 
   88     /**
   89      * @Flow\Inject
   90      * @var NodeShortcutResolver
   91      */
   92     protected $nodeShortcutResolver;
   93 
   94     /**
   95      * @var Site[] indexed by the corresponding host name
   96      */
   97     protected $siteByHostRuntimeCache = [];
   98 
   99     const DIMENSION_REQUEST_PATH_MATCHER = '|^
  100         (?<firstUriPart>[^/@]+)                    # the first part of the URI, before the first slash, may contain the encoded dimension preset
  101         (?:                                        # start of non-capturing submatch for the remaining URL
  102             /?                                     # a "/"; optional. it must also match en@user-admin
  103             (?<remainingRequestPath>.*)            # the remaining request path
  104         )?                                         # ... and this whole remaining URL is optional
  105         $                                          # make sure we consume the full string
  106     |x';
  107 
  108     /**
  109      * Extracts the node path from the request path.
  110      *
  111      * @param string $requestPath The request path to be matched
  112      * @return string value to match, or an empty string if $requestPath is empty or split string was not found
  113      */
  114     protected function findValueToMatch($requestPath)
  115     {
  116         if ($this->splitString !== '') {
  117             $splitStringPosition = strpos($requestPath, $this->splitString);
  118             if ($splitStringPosition !== false) {
  119                 return substr($requestPath, 0, $splitStringPosition);
  120             }
  121         }
  122 
  123         return $requestPath;
  124     }
  125 
  126     /**
  127      * Matches a frontend URI pointing to a node (for example a page).
  128      *
  129      * This function tries to find a matching node by the given request path. If one was found, its
  130      * absolute context node path is set in $this->value and true is returned.
  131      *
  132      * Note that this matcher does not check if access to the resolved workspace or node is allowed because at the point
  133      * in time the route part handler is invoked, the security framework is not yet fully initialized.
  134      *
  135      * @param string $requestPath The request path (without leading "/", relative to the current Site Node)
  136      * @return bool|MatchResult An instance of MatchResult if the route matches the $requestPath, otherwise FALSE. @see DynamicRoutePart::matchValue()
  137      * @throws \Exception
  138      * @throws Exception\NoHomepageException if no node could be found on the homepage (empty $requestPath)
  139      */
  140     protected function matchValue($requestPath)
  141     {
  142         try {
  143             /** @var NodeInterface $node */
  144             $node = null;
  145 
  146             // Build context explicitly without authorization checks because the security context isn't available yet
  147             // anyway and any Entity Privilege targeted on Workspace would fail at this point:
  148             $this->securityContext->withoutAuthorizationChecks(function () use (&$node, $requestPath) {
  149                 $node = $this->convertRequestPathToNode($requestPath);
  150             });
  151         } catch (Exception $exception) {
  152             $this->systemLogger->debug('FrontendNodeRoutePartHandler matchValue(): ' . $exception->getMessage());
  153             if ($requestPath === '') {
  154                 throw new Exception\NoHomepageException('Homepage could not be loaded. Probably you haven\'t imported a site yet', 1346950755, $exception);
  155             }
  156 
  157             return false;
  158         }
  159         if (!$this->nodeTypeIsAllowed($node)) {
  160             return false;
  161         }
  162         if ($this->onlyMatchSiteNodes() && $node !== $node->getContext()->getCurrentSiteNode()) {
  163             return false;
  164         }
  165 
  166         return new MatchResult($node->getContextPath());
  167     }
  168 
  169     /**
  170      * Returns the initialized node that is referenced by $requestPath, based on the node's
  171      * "uriPathSegment" property.
  172      *
  173      * Note that $requestPath will be modified (passed by reference) by buildContextFromRequestPath().
  174      *
  175      * @param string $requestPath The request path, for example /the/node/path@some-workspace
  176      * @return NodeInterface
  177      * @throws \Neos\Neos\Routing\Exception\NoWorkspaceException
  178      * @throws \Neos\Neos\Routing\Exception\NoSiteException
  179      * @throws \Neos\Neos\Routing\Exception\NoSuchNodeException
  180      * @throws \Neos\Neos\Routing\Exception\NoSiteNodeException
  181      * @throws \Neos\Neos\Routing\Exception\InvalidRequestPathException
  182      */
  183     protected function convertRequestPathToNode($requestPath)
  184     {
  185         $contentContext = $this->buildContextFromRequestPath($requestPath);
  186         $requestPathWithoutContext = $this->removeContextFromPath($requestPath);
  187 
  188         $workspace = $contentContext->getWorkspace();
  189         if ($workspace === null) {
  190             throw new Exception\NoWorkspaceException(sprintf('No workspace found for request path "%s"', $requestPath), 1346949318);
  191         }
  192 
  193         $site = $contentContext->getCurrentSite();
  194         if ($site === null) {
  195             throw new Exception\NoSiteException(sprintf('No site found for request path "%s"', $requestPath), 1346949693);
  196         }
  197 
  198         $siteNode = $contentContext->getCurrentSiteNode();
  199         if ($siteNode === null) {
  200             $currentDomain = $contentContext->getCurrentDomain() ? 'Domain with hostname "' . $contentContext->getCurrentDomain()->getHostname() . '" matched.' : 'No specific domain matched.';
  201             throw new Exception\NoSiteNodeException(sprintf('No site node found for request path "%s". %s', $requestPath, $currentDomain), 1346949728);
  202         }
  203 
  204         if ($requestPathWithoutContext === '') {
  205             $node = $siteNode;
  206         } else {
  207             $requestPathWithoutContext = $this->truncateUriPathSuffix((string)$requestPathWithoutContext);
  208             $relativeNodePath = $this->getRelativeNodePathByUriPathSegmentProperties($siteNode, $requestPathWithoutContext);
  209             $node = ($relativeNodePath !== false) ? $siteNode->getNode($relativeNodePath) : null;
  210         }
  211 
  212         if (!$node instanceof NodeInterface) {
  213             throw new Exception\NoSuchNodeException(sprintf('No node found on request path "%s"', $requestPath), 1346949857);
  214         }
  215 
  216         return $node;
  217     }
  218 
  219     /**
  220      * Checks, whether given value is a Node object and if so, sets $this->value to the respective node path.
  221      *
  222      * In order to render a suitable frontend URI, this function strips off the path to the site node and only keeps
  223      * the actual node path relative to that site node. In practice this function would set $this->value as follows:
  224      *
  225      * absolute node path: /sites/neostypo3org/homepage/about
  226      * $this->value:       homepage/about
  227      *
  228      * absolute node path: /sites/neostypo3org/homepage/about@user-admin
  229      * $this->value:       homepage/about@user-admin
  230      *
  231      * @param NodeInterface|string|string[] $node Either a Node object or an absolute context node path (potentially wrapped in an array as ['__contextNodePath' => '<value>'])
  232      * @return bool|ResolveResult An instance of ResolveResult if the route coulr resolve the $node, otherwise FALSE. @see DynamicRoutePart::resolveValue()
  233      * @throws Exception\NoSiteException | InvalidRequestPathException | NeosException | IllegalObjectTypeException
  234      * @see NodeIdentityConverterAspect
  235      */
  236     protected function resolveValue($node)
  237     {
  238         if (is_array($node) && isset($node['__contextNodePath'])) {
  239             $node = $node['__contextNodePath'];
  240         }
  241         if (!$node instanceof NodeInterface && !is_string($node)) {
  242             return false;
  243         }
  244 
  245         if (is_string($node)) {
  246             $nodeContextPath = $node;
  247             $contentContext = $this->buildContextFromPath($nodeContextPath, true);
  248             if ($contentContext->getWorkspace() === null) {
  249                 return false;
  250             }
  251             $nodePath = $this->removeContextFromPath($nodeContextPath);
  252             $node = $contentContext->getNode($nodePath);
  253 
  254             if ($node === null) {
  255                 return false;
  256             }
  257         } else {
  258             $contentContext = $node->getContext();
  259         }
  260 
  261         if (!$this->nodeTypeIsAllowed($node)) {
  262             return false;
  263         }
  264         $siteNode = $contentContext->getCurrentSiteNode();
  265         if ($this->onlyMatchSiteNodes() && $node !== $siteNode) {
  266             return false;
  267         }
  268 
  269         try {
  270             $nodeOrUri = $this->resolveShortcutNode($node);
  271         } catch (Exception\InvalidShortcutException $exception) {
  272             $this->systemLogger->debug('FrontendNodeRoutePartHandler resolveValue(): ' . $exception->getMessage());
  273             return false;
  274         }
  275         if ($nodeOrUri instanceof UriInterface) {
  276             return new ResolveResult('', UriConstraints::fromUri($nodeOrUri), null);
  277         }
  278 
  279         try {
  280             $uriConstraints = $this->buildUriConstraintsForResolvedNode($nodeOrUri);
  281         } catch (Exception\NoSiteException $exception) {
  282             $this->systemLogger->debug('FrontendNodeRoutePartHandler resolveValue(): ' . $exception->getMessage());
  283             return false;
  284         }
  285         $uriPath = $this->resolveRoutePathForNode($nodeOrUri);
  286         return new ResolveResult($uriPath, $uriConstraints);
  287     }
  288 
  289     /**
  290      * Removes the configured suffix from the given $uriPath
  291      * If the "uriPathSuffix" option is not set (or set to an empty string) the unaltered $uriPath is returned
  292      *
  293      * @param string $uriPath
  294      * @return false|string|null
  295      * @throws Exception\InvalidRequestPathException
  296      */
  297     protected function truncateUriPathSuffix(string $uriPath)
  298     {
  299         if (empty($this->options['uriPathSuffix'])) {
  300             return $uriPath;
  301         }
  302         $suffixLength = strlen($this->options['uriPathSuffix']);
  303         if (substr($uriPath, -$suffixLength) !== $this->options['uriPathSuffix']) {
  304             throw new Exception\InvalidRequestPathException(sprintf('The request path "%s" doesn\'t contain the configured uriPathSuffix "%s"', $uriPath, $this->options['uriPathSuffix']), 1604912439);
  305         }
  306         return substr($uriPath, 0, -$suffixLength);
  307     }
  308 
  309     /**
  310      * @param NodeInterface $node
  311      * @return NodeInterface|Uri The original, unaltered $node if it's not a shortcut node. Otherwise the nodes shortcut target (a node or an URI for external & asset shortcuts)
  312      * @throws Exception\InvalidShortcutException
  313      */
  314     protected function resolveShortcutNode(NodeInterface $node)
  315     {
  316         $resolvedNode = $this->nodeShortcutResolver->resolveShortcutTarget($node);
  317         if (is_string($resolvedNode)) {
  318             return new Uri($resolvedNode);
  319         }
  320         if (!$resolvedNode instanceof NodeInterface) {
  321             throw new Exception\InvalidShortcutException(sprintf('Could not resolve shortcut target for node "%s"', $node->getPath()), 1414771137);
  322         }
  323         return $resolvedNode;
  324     }
  325 
  326     /**
  327      * Builds UriConstraints for the given $node with:
  328      * * domain specific constraints for nodes in a different Neos site
  329      * * a path suffix corresponding to the configured "uriPathSuffix"
  330      *
  331      * @param NodeInterface $node
  332      * @return UriConstraints
  333      * @throws Exception\NoSiteException This exception will be caught in resolveValue()
  334      */
  335     protected function buildUriConstraintsForResolvedNode(NodeInterface $node): UriConstraints
  336     {
  337         $uriConstraints = UriConstraints::create();
  338         $requestSite = $this->getCurrentSite();
  339         if (!NodePaths::isSubPathOf(SiteService::SITES_ROOT_PATH, $node->getPath())) {
  340             throw new Exception\NoSiteException(sprintf('The node at path "%s" is not located underneath the sites root path "%s"', $node->getPath(), SiteService::SITES_ROOT_PATH), 1604922914);
  341         }
  342         $resolvedSiteNodeName = strtok(NodePaths::getRelativePathBetween(SiteService::SITES_ROOT_PATH, $node->getPath()), '/');
  343         if ($resolvedSiteNodeName !== $requestSite->getNodeName()) {
  344             $resolvedSite = $this->siteRepository->findOneByNodeName($resolvedSiteNodeName);
  345             if ($resolvedSite === null || $resolvedSite->isOffline()) {
  346                 throw new Exception\NoSiteException(sprintf('No online site found for node "%s" and resolved site node name of "%s"', $node->getIdentifier(), $resolvedSiteNodeName), 1604505599);
  347             }
  348             $uriConstraints = $this->applyDomainToUriConstraints($uriConstraints, $resolvedSite->getPrimaryDomain());
  349         }
  350         if (!empty($this->options['uriPathSuffix']) && $node->getParentPath() !== SiteService::SITES_ROOT_PATH) {
  351             $uriConstraints = $uriConstraints->withPathSuffix($this->options['uriPathSuffix']);
  352         }
  353         return $uriConstraints;
  354     }
  355 
  356     /**
  357      * @param UriConstraints $uriConstraints
  358      * @param Domain|null $domain
  359      * @return UriConstraints
  360      */
  361     protected function applyDomainToUriConstraints(UriConstraints $uriConstraints, ?Domain $domain): UriConstraints
  362     {
  363         if ($domain === null) {
  364             return $uriConstraints;
  365         }
  366         $uriConstraints = $uriConstraints->withHost($domain->getHostname());
  367         if (!empty($domain->getScheme())) {
  368             $uriConstraints = $uriConstraints->withScheme($domain->getScheme());
  369         }
  370         if (!empty($domain->getPort())) {
  371             $uriConstraints = $uriConstraints->withPort($domain->getPort());
  372         }
  373         return $uriConstraints;
  374     }
  375 
  376     /**
  377      * Creates a content context from the given request path, considering possibly mentioned content dimension values.
  378      *
  379      * @param string &$requestPath The request path. If at least one content dimension is configured, the first path segment will identify the content dimension values
  380      * @return ContentContext The built content context
  381      */
  382     protected function buildContextFromRequestPath(&$requestPath)
  383     {
  384         $workspaceName = 'live';
  385         $dimensionsAndDimensionValues = $this->parseDimensionsAndNodePathFromRequestPath($requestPath);
  386 
  387         // This is a workaround as NodePaths::explodeContextPath() (correctly)
  388         // expects a context path to have something before the '@', but the requestPath
  389         // could potentially contain only the context information.
  390         if (strpos($requestPath, '@') === 0) {
  391             $requestPath = '/' . $requestPath;
  392         }
  393 
  394         if ($requestPath !== '' && NodePaths::isContextPath($requestPath)) {
  395             try {
  396                 $nodePathAndContext = NodePaths::explodeContextPath($requestPath);
  397                 $workspaceName = $nodePathAndContext['workspaceName'];
  398             } catch (\InvalidArgumentException $exception) {
  399             }
  400         }
  401         return $this->buildContextFromWorkspaceNameAndDimensions($workspaceName, $dimensionsAndDimensionValues);
  402     }
  403 
  404     /**
  405      * Creates a content context from the given "context path", i.e. a string used for _resolving_ (not matching) a node.
  406      *
  407      * @param string $path a path containing the context, such as /sites/examplecom/home@user-johndoe or /assets/pictures/my-picture or /assets/pictures/my-picture@user-john;language=de&country=global
  408      * @param boolean $convertLiveDimensions Whether to parse dimensions from the context path in a non-live workspace
  409      * @return ContentContext based on the specified path; only evaluating the context information (i.e. everything after "@")
  410      * @throws Exception\InvalidRequestPathException
  411      */
  412     protected function buildContextFromPath($path, $convertLiveDimensions)
  413     {
  414         $workspaceName = 'live';
  415         $dimensions = null;
  416 
  417         if ($path !== '' && NodePaths::isContextPath($path)) {
  418             $nodePathAndContext = NodePaths::explodeContextPath($path);
  419             $workspaceName = $nodePathAndContext['workspaceName'];
  420             $dimensions = ($workspaceName !== 'live' || $convertLiveDimensions === true) ? $nodePathAndContext['dimensions'] : null;
  421         }
  422 
  423         return $this->buildContextFromWorkspaceName($workspaceName, $dimensions);
  424     }
  425 
  426     /**
  427      * @param string $workspaceName
  428      * @param array|null $dimensions
  429      * @return ContentContext
  430      * @throws Exception\NoSiteException
  431      */
  432     protected function buildContextFromWorkspaceName($workspaceName, array $dimensions = null)
  433     {
  434         $contextProperties = [
  435             'workspaceName' => $workspaceName,
  436             'currentSite' => $this->getCurrentSite(),
  437         ];
  438 
  439         if ($dimensions !== null) {
  440             $contextProperties['dimensions'] = $dimensions;
  441         }
  442 
  443         return $this->contextFactory->create($contextProperties);
  444     }
  445 
  446     /**
  447      * @param string $path an absolute or relative node path which possibly contains context information, for example "/sites/somesite/the/node/path@some-workspace"
  448      * @return string the same path without context information
  449      */
  450     protected function removeContextFromPath($path)
  451     {
  452         if ($path === '' || NodePaths::isContextPath($path) === false) {
  453             return $path;
  454         }
  455         try {
  456             $nodePathAndContext = NodePaths::explodeContextPath($path);
  457             // This is a workaround as we potentially prepend the context path with "/" in buildContextFromRequestPath to create a valid context path,
  458             // the code in this class expects an empty nodePath though for the site node, so we remove it again at this point.
  459             return $nodePathAndContext['nodePath'] === '/' ? '' : $nodePathAndContext['nodePath'];
  460         } catch (\InvalidArgumentException $exception) {
  461         }
  462 
  463         return null;
  464     }
  465 
  466     /**
  467      * Whether the current route part should only match/resolve site nodes (e.g. the homepage)
  468      *
  469      * @return boolean
  470      */
  471     protected function onlyMatchSiteNodes()
  472     {
  473         return isset($this->options['onlyMatchSiteNodes']) && $this->options['onlyMatchSiteNodes'] === true;
  474     }
  475 
  476     /**
  477      * Whether the given $node is allowed according to the "nodeType" option
  478      *
  479      * @param NodeInterface $node
  480      * @return bool
  481      */
  482     protected function nodeTypeIsAllowed(NodeInterface $node): bool
  483     {
  484         $allowedNodeType = !empty($this->options['nodeType']) ? $this->options['nodeType'] : 'Neos.Neos:Document';
  485         return $node->getNodeType()->isOfType($allowedNodeType);
  486     }
  487 
  488     /**
  489      * Resolves the request path, also known as route path, identifying the given node.
  490      *
  491      * A path is built, based on the uri path segment properties of the parents of and the given node itself.
  492      * If content dimensions are configured, the first path segment will the identifiers of the dimension
  493      * values according to the current context.
  494      *
  495      * @param NodeInterface $node The node where the generated path should lead to
  496      * @return string The relative route path, possibly prefixed with a segment for identifying the current content dimension values
  497      */
  498     protected function resolveRoutePathForNode(NodeInterface $node)
  499     {
  500         $workspaceName = $node->getContext()->getWorkspaceName();
  501 
  502         $nodeContextPath = $node->getContextPath();
  503         $nodeContextPathSuffix = ($workspaceName !== 'live') ? substr($nodeContextPath, strpos($nodeContextPath, '@')) : '';
  504 
  505         $currentNodeIsSiteNode = ($node->getParentPath() === SiteService::SITES_ROOT_PATH);
  506         $dimensionsUriSegment = $this->getUriSegmentForDimensions($node->getContext()->getDimensions(), $currentNodeIsSiteNode);
  507         $requestPath = $this->getRequestPathByNode($node);
  508 
  509         return trim($dimensionsUriSegment . $requestPath, '/') . $nodeContextPathSuffix;
  510     }
  511 
  512     /**
  513      * Builds a node path which matches the given request path.
  514      *
  515      * This method traverses the segments of the given request path and tries to find nodes on the current level which
  516      * have a matching "uriPathSegment" property. If no node could be found which would match the given request path,
  517      * false is returned.
  518      *
  519      * @param NodeInterface $siteNode The site node, used as a starting point while traversing the tree
  520      * @param string $relativeRequestPath The request path, relative to the site's root path
  521      * @throws \Neos\Neos\Routing\Exception\NoSuchNodeException
  522      * @return string
  523      */
  524     protected function getRelativeNodePathByUriPathSegmentProperties(NodeInterface $siteNode, $relativeRequestPath)
  525     {
  526         $relativeNodePathSegments = [];
  527         $node = $siteNode;
  528 
  529         foreach (explode('/', $relativeRequestPath) as $pathSegment) {
  530             $foundNodeInThisSegment = false;
  531             foreach ($node->getChildNodes('Neos.Neos:Document') as $node) {
  532                 /** @var NodeInterface $node */
  533                 if ($node->getProperty('uriPathSegment') === $pathSegment) {
  534                     $relativeNodePathSegments[] = $node->getName();
  535                     $foundNodeInThisSegment = true;
  536                     break;
  537                 }
  538             }
  539             if (!$foundNodeInThisSegment) {
  540                 return false;
  541             }
  542         }
  543 
  544         return implode('/', $relativeNodePathSegments);
  545     }
  546 
  547     /**
  548      * Renders a request path based on the "uriPathSegment" properties of the nodes leading to the given node.
  549      *
  550      * @param NodeInterface $node The node where the generated path should lead to
  551      * @return string A relative request path
  552      * @throws Exception\MissingNodePropertyException if the given node doesn't have a "uriPathSegment" property set
  553      */
  554     protected function getRequestPathByNode(NodeInterface $node)
  555     {
  556         if ($node->getParentPath() === SiteService::SITES_ROOT_PATH) {
  557             return '';
  558         }
  559 
  560         // To allow building of paths to non-hidden nodes beneath hidden nodes, we assume
  561         // the input node is allowed to be seen and we must generate the full path here.
  562         // To disallow showing a node actually hidden itself has to be ensured in matching
  563         // a request path, not in building one.
  564         $contextProperties = $node->getContext()->getProperties();
  565         $contextAllowingHiddenNodes = $this->contextFactory->create(array_merge($contextProperties, ['invisibleContentShown' => true]));
  566         $currentNode = $contextAllowingHiddenNodes->getNodeByIdentifier($node->getIdentifier());
  567 
  568         $requestPathSegments = [];
  569         while ($currentNode instanceof NodeInterface && $currentNode->getParentPath() !== SiteService::SITES_ROOT_PATH) {
  570             if (!$currentNode->hasProperty('uriPathSegment')) {
  571                 throw new Exception\MissingNodePropertyException(sprintf('Missing "uriPathSegment" property for node "%s". Nodes can be migrated with the "flow node:repair" command.', $node->getPath()), 1415020326);
  572             }
  573 
  574             $pathSegment = $currentNode->getProperty('uriPathSegment');
  575             $requestPathSegments[] = $pathSegment;
  576             $currentNode = $currentNode->getParent();
  577         }
  578 
  579         return implode('/', array_reverse($requestPathSegments));
  580     }
  581 
  582     /**
  583     * Choose between default method for parsing dimensions or the one which allows uriSegment to be empty for default preset.
  584     *
  585     * @param string &$requestPath The request path currently being processed by this route part handler, e.g. "de_global/startseite/ueber-uns"
  586     * @return array An array of dimension name => dimension values (array of string)
  587     */
  588     protected function parseDimensionsAndNodePathFromRequestPath(&$requestPath)
  589     {
  590         if ($this->supportEmptySegmentForDimensions) {
  591             $dimensionsAndDimensionValues = $this->parseDimensionsAndNodePathFromRequestPathAllowingEmptySegment($requestPath);
  592         } else {
  593             $dimensionsAndDimensionValues = $this->parseDimensionsAndNodePathFromRequestPathAllowingNonUniqueSegment($requestPath);
  594         }
  595         return $dimensionsAndDimensionValues;
  596     }
  597 
  598     /**
  599      * Parses the given request path and checks if the first path segment is one or a set of content dimension preset
  600      * identifiers. If that is the case, the return value is an array of dimension names and their preset URI segments.
  601      * Allows uriSegment to be empty for default dimension preset.
  602      *
  603      * If the first path segment contained content dimension information, it is removed from &$requestPath.
  604      *
  605      * @param string &$requestPath The request path currently being processed by this route part handler, e.g. "de_global/startseite/ueber-uns"
  606      * @return array An array of dimension name => dimension values (array of string)
  607      * @throws InvalidDimensionPresetCombinationException
  608      */
  609     protected function parseDimensionsAndNodePathFromRequestPathAllowingEmptySegment(&$requestPath)
  610     {
  611         $dimensionPresets = $this->contentDimensionPresetSource->getAllPresets();
  612         if (count($dimensionPresets) === 0) {
  613             return [];
  614         }
  615         $dimensionsAndDimensionValues = [];
  616         $chosenDimensionPresets = [];
  617         $matches = [];
  618         preg_match(self::DIMENSION_REQUEST_PATH_MATCHER, $requestPath, $matches);
  619         $firstUriPartIsValidDimension = true;
  620         foreach ($dimensionPresets as $dimensionName => $dimensionPreset) {
  621             $dimensionsAndDimensionValues[$dimensionName] = $dimensionPreset['presets'][$dimensionPreset['defaultPreset']]['values'];
  622             $chosenDimensionPresets[$dimensionName] = $dimensionPreset['defaultPreset'];
  623         }
  624         if (isset($matches['firstUriPart'])) {
  625             $firstUriPartExploded = explode('_', $matches['firstUriPart']);
  626             foreach ($firstUriPartExploded as $uriSegment) {
  627                 $uriSegmentIsValid = false;
  628                 foreach ($dimensionPresets as $dimensionName => $dimensionPreset) {
  629                     $preset = $this->contentDimensionPresetSource->findPresetByUriSegment($dimensionName, $uriSegment);
  630                     if ($preset !== null) {
  631                         $uriSegmentIsValid = true;
  632                         $dimensionsAndDimensionValues[$dimensionName] = $preset['values'];
  633                         $chosenDimensionPresets[$dimensionName] = $preset['identifier'];
  634                         break;
  635                     }
  636                 }
  637                 if (!$uriSegmentIsValid) {
  638                     $firstUriPartIsValidDimension = false;
  639                     break;
  640                 }
  641             }
  642             if ($firstUriPartIsValidDimension) {
  643                 $requestPath = (isset($matches['remainingRequestPath']) ? $matches['remainingRequestPath'] : '');
  644             }
  645         }
  646         if (!$this->contentDimensionPresetSource->isPresetCombinationAllowedByConstraints($chosenDimensionPresets)) {
  647             throw new InvalidDimensionPresetCombinationException(sprintf('The resolved content dimension preset combination (%s) is invalid or restricted by content dimension constraints. Check your content dimension settings if you think that this is an error.', implode(', ', array_keys($chosenDimensionPresets))), 1428657721);
  648         }
  649         return $dimensionsAndDimensionValues;
  650     }
  651 
  652     /**
  653      * Parses the given request path and checks if the first path segment is one or a set of content dimension preset
  654      * identifiers. If that is the case, the return value is an array of dimension names and their preset URI segments.
  655      * Doesn't allow empty uriSegment, but allows uriSegment to be not unique across presets.
  656      *
  657      * If the first path segment contained content dimension information, it is removed from &$requestPath.
  658      *
  659      * @param string &$requestPath The request path currently being processed by this route part handler, e.g. "de_global/startseite/ueber-uns"
  660      * @return array An array of dimension name => dimension values (array of string)
  661      * @throws InvalidDimensionPresetCombinationException
  662      * @throws InvalidRequestPathException
  663      * @throws NoSuchDimensionValueException
  664      */
  665     protected function parseDimensionsAndNodePathFromRequestPathAllowingNonUniqueSegment(&$requestPath)
  666     {
  667         $dimensionPresets = $this->contentDimensionPresetSource->getAllPresets();
  668         if (count($dimensionPresets) === 0) {
  669             return [];
  670         }
  671 
  672         $dimensionsAndDimensionValues = [];
  673         $chosenDimensionPresets = [];
  674         $matches = [];
  675 
  676         preg_match(self::DIMENSION_REQUEST_PATH_MATCHER, $requestPath, $matches);
  677 
  678         if (!isset($matches['firstUriPart'])) {
  679             foreach ($dimensionPresets as $dimensionName => $dimensionPreset) {
  680                 $dimensionsAndDimensionValues[$dimensionName] = $dimensionPreset['presets'][$dimensionPreset['defaultPreset']]['values'];
  681                 $chosenDimensionPresets[$dimensionName] = $dimensionPreset['defaultPreset'];
  682             }
  683         } else {
  684             $firstUriPart = explode('_', $matches['firstUriPart']);
  685 
  686             if (count($firstUriPart) !== count($dimensionPresets)) {
  687                 throw new InvalidRequestPathException(sprintf('The first path segment of the request URI (%s) does not contain the necessary content dimension preset identifiers for all configured dimensions. This might be an old URI which doesn\'t match the current dimension configuration anymore.', $requestPath), 1413389121);
  688             }
  689 
  690             foreach ($dimensionPresets as $dimensionName => $dimensionPreset) {
  691                 $uriSegment = array_shift($firstUriPart);
  692                 $preset = $this->contentDimensionPresetSource->findPresetByUriSegment($dimensionName, $uriSegment);
  693                 if ($preset === null) {
  694                     throw new NoSuchDimensionValueException(sprintf('Could not find a preset for content dimension "%s" through the given URI segment "%s".', $dimensionName, $uriSegment), 1413389321);
  695                 }
  696                 $dimensionsAndDimensionValues[$dimensionName] = $preset['values'];
  697                 $chosenDimensionPresets[$dimensionName] = $preset['identifier'];
  698             }
  699 
  700             $requestPath = (isset($matches['remainingRequestPath']) ? $matches['remainingRequestPath'] : '');
  701         }
  702 
  703         if (!$this->contentDimensionPresetSource->isPresetCombinationAllowedByConstraints($chosenDimensionPresets)) {
  704             throw new InvalidDimensionPresetCombinationException(sprintf('The resolved content dimension preset combination (%s) is invalid or restricted by content dimension constraints. Check your content dimension settings if you think that this is an error.', implode(', ', array_keys($chosenDimensionPresets))), 1462175794);
  705         }
  706 
  707         return $dimensionsAndDimensionValues;
  708     }
  709 
  710     /**
  711      * Sets context properties like "invisibleContentShown" according to the workspace (live or not) and returns a
  712      * ContentContext object.
  713      *
  714      * @param string $workspaceName Name of the workspace to use in the context
  715      * @param array $dimensionsAndDimensionValues An array of dimension names (index) and their values (array of strings). See also: ContextFactory
  716      * @return ContentContext
  717      * @throws Exception\NoSiteException
  718      */
  719     protected function buildContextFromWorkspaceNameAndDimensions($workspaceName, array $dimensionsAndDimensionValues)
  720     {
  721         $contextProperties = [
  722             'workspaceName' => $workspaceName,
  723             'invisibleContentShown' => ($workspaceName !== 'live'),
  724             'inaccessibleContentShown' => ($workspaceName !== 'live'),
  725             'dimensions' => $dimensionsAndDimensionValues,
  726             'currentSite' => $this->getCurrentSite(),
  727         ];
  728 
  729         return $this->contextFactory->create($contextProperties);
  730     }
  731 
  732     /**
  733      * Determines the currently active site based on the "requestUriHost" parameter (that has to be set via HTTP middleware)
  734      *
  735      * @return Site
  736      * @throws Exception\NoSiteException
  737      */
  738     protected function getCurrentSite(): Site
  739     {
  740         $requestUriHost = $this->parameters->getValue('requestUriHost');
  741         if (!is_string($requestUriHost)) {
  742             throw new Exception\NoSiteException('Failed to determine current site because the "requestUriHost" Routing parameter is not set', 1604860219);
  743         }
  744         if (!array_key_exists($requestUriHost, $this->siteByHostRuntimeCache)) {
  745             $this->siteByHostRuntimeCache[$requestUriHost] = $this->getSiteByHostName($requestUriHost);
  746         }
  747         return $this->siteByHostRuntimeCache[$requestUriHost];
  748     }
  749 
  750     /**
  751      * Returns a site matching the given $hostName
  752      *
  753      * @param string $hostName
  754      * @return Site
  755      * @throws Exception\NoSiteException
  756      */
  757     protected function getSiteByHostName(string $hostName): Site
  758     {
  759         $domain = $this->domainRepository->findOneByHost($hostName, true);
  760         if ($domain !== null) {
  761             return $domain->getSite();
  762         }
  763         try {
  764             $defaultSite = $this->siteRepository->findDefault();
  765             if ($defaultSite === null) {
  766                 throw new Exception\NoSiteException('Failed to determine current site because no default site is configured', 1604929674);
  767             }
  768         } catch (NeosException $exception) {
  769             throw new Exception\NoSiteException(sprintf('Failed to determine current site because no domain is specified matching host of "%s" and no default site could be found: %s', $hostName, $exception->getMessage()), 1604860219, $exception);
  770         }
  771         return $defaultSite;
  772     }
  773 
  774     /**
  775      * Find a URI segment in the content dimension presets for the given "language" dimension values
  776      *
  777      * This will do a reverse lookup from actual dimension values to a preset and fall back to the default preset if none
  778      * can be found.
  779      *
  780      * @param array $dimensionsValues An array of dimensions and their values, indexed by dimension name
  781      * @param boolean $currentNodeIsSiteNode If the current node is actually the site node
  782      * @return string
  783      * @throws \Exception
  784      */
  785     protected function getUriSegmentForDimensions(array $dimensionsValues, $currentNodeIsSiteNode)
  786     {
  787         $uriSegment = '';
  788         $allDimensionPresetsAreDefault = true;
  789 
  790         foreach ($this->contentDimensionPresetSource->getAllPresets() as $dimensionName => $dimensionPresets) {
  791             $preset = null;
  792             if (isset($dimensionsValues[$dimensionName])) {
  793                 $preset = $this->contentDimensionPresetSource->findPresetByDimensionValues($dimensionName, $dimensionsValues[$dimensionName]);
  794             }
  795             $defaultPreset = $this->contentDimensionPresetSource->getDefaultPreset($dimensionName);
  796             if ($preset === null) {
  797                 $preset = $defaultPreset;
  798             }
  799             if ($preset !== $defaultPreset) {
  800                 $allDimensionPresetsAreDefault = false;
  801             }
  802             if (!isset($preset['uriSegment'])) {
  803                 throw new \Exception(sprintf('No "uriSegment" configured for content dimension preset "%s" for dimension "%s". Please check the content dimension configuration in Settings.yaml', $preset['identifier'], $dimensionName), 1395824520);
  804             }
  805             $uriSegment .= $preset['uriSegment'] . '_';
  806         }
  807 
  808         if ($this->supportEmptySegmentForDimensions && $allDimensionPresetsAreDefault && $currentNodeIsSiteNode) {
  809             return '/';
  810         } else {
  811             return ltrim(trim($uriSegment, '_') . '/', '/');
  812         }
  813     }
  814 }