Chris@0: Chris@0: * Chris@0: * For the full copyright and license information, please view the LICENSE Chris@0: * file that was distributed with this source code. Chris@0: */ Chris@0: Chris@0: namespace Symfony\Component\Routing\Generator; Chris@0: Chris@17: use Psr\Log\LoggerInterface; Chris@17: use Symfony\Component\Routing\Exception\InvalidParameterException; Chris@17: use Symfony\Component\Routing\Exception\MissingMandatoryParametersException; Chris@17: use Symfony\Component\Routing\Exception\RouteNotFoundException; Chris@17: use Symfony\Component\Routing\RequestContext; Chris@0: use Symfony\Component\Routing\RouteCollection; Chris@0: Chris@0: /** Chris@0: * UrlGenerator can generate a URL or a path for any route in the RouteCollection Chris@0: * based on the passed parameters. Chris@0: * Chris@0: * @author Fabien Potencier Chris@0: * @author Tobias Schultze Chris@0: */ Chris@0: class UrlGenerator implements UrlGeneratorInterface, ConfigurableRequirementsInterface Chris@0: { Chris@0: protected $routes; Chris@0: protected $context; Chris@0: Chris@0: /** Chris@0: * @var bool|null Chris@0: */ Chris@0: protected $strictRequirements = true; Chris@0: Chris@0: protected $logger; Chris@0: Chris@0: /** Chris@0: * This array defines the characters (besides alphanumeric ones) that will not be percent-encoded in the path segment of the generated URL. Chris@0: * Chris@0: * PHP's rawurlencode() encodes all chars except "a-zA-Z0-9-._~" according to RFC 3986. But we want to allow some chars Chris@0: * to be used in their literal form (reasons below). Other chars inside the path must of course be encoded, e.g. Chris@0: * "?" and "#" (would be interpreted wrongly as query and fragment identifier), Chris@0: * "'" and """ (are used as delimiters in HTML). Chris@0: */ Chris@17: protected $decodedChars = [ Chris@0: // the slash can be used to designate a hierarchical structure and we want allow using it with this meaning Chris@0: // some webservers don't allow the slash in encoded form in the path for security reasons anyway Chris@0: // see http://stackoverflow.com/questions/4069002/http-400-if-2f-part-of-get-url-in-jboss Chris@0: '%2F' => '/', Chris@0: // the following chars are general delimiters in the URI specification but have only special meaning in the authority component Chris@0: // so they can safely be used in the path in unencoded form Chris@0: '%40' => '@', Chris@0: '%3A' => ':', Chris@0: // these chars are only sub-delimiters that have no predefined meaning and can therefore be used literally Chris@0: // so URI producing applications can use these chars to delimit subcomponents in a path segment without being encoded for better readability Chris@0: '%3B' => ';', Chris@0: '%2C' => ',', Chris@0: '%3D' => '=', Chris@0: '%2B' => '+', Chris@0: '%21' => '!', Chris@0: '%2A' => '*', Chris@0: '%7C' => '|', Chris@17: ]; Chris@0: Chris@0: public function __construct(RouteCollection $routes, RequestContext $context, LoggerInterface $logger = null) Chris@0: { Chris@0: $this->routes = $routes; Chris@0: $this->context = $context; Chris@0: $this->logger = $logger; Chris@0: } Chris@0: Chris@0: /** Chris@0: * {@inheritdoc} Chris@0: */ Chris@0: public function setContext(RequestContext $context) Chris@0: { Chris@0: $this->context = $context; Chris@0: } Chris@0: Chris@0: /** Chris@0: * {@inheritdoc} Chris@0: */ Chris@0: public function getContext() Chris@0: { Chris@0: return $this->context; Chris@0: } Chris@0: Chris@0: /** Chris@0: * {@inheritdoc} Chris@0: */ Chris@0: public function setStrictRequirements($enabled) Chris@0: { Chris@0: $this->strictRequirements = null === $enabled ? null : (bool) $enabled; Chris@0: } Chris@0: Chris@0: /** Chris@0: * {@inheritdoc} Chris@0: */ Chris@0: public function isStrictRequirements() Chris@0: { Chris@0: return $this->strictRequirements; Chris@0: } Chris@0: Chris@0: /** Chris@0: * {@inheritdoc} Chris@0: */ Chris@17: public function generate($name, $parameters = [], $referenceType = self::ABSOLUTE_PATH) Chris@0: { Chris@0: if (null === $route = $this->routes->get($name)) { Chris@0: throw new RouteNotFoundException(sprintf('Unable to generate a URL for the named route "%s" as such route does not exist.', $name)); Chris@0: } Chris@0: Chris@0: // the Route has a cache of its own and is not recompiled as long as it does not get modified Chris@0: $compiledRoute = $route->compile(); Chris@0: Chris@0: return $this->doGenerate($compiledRoute->getVariables(), $route->getDefaults(), $route->getRequirements(), $compiledRoute->getTokens(), $parameters, $name, $referenceType, $compiledRoute->getHostTokens(), $route->getSchemes()); Chris@0: } Chris@0: Chris@0: /** Chris@0: * @throws MissingMandatoryParametersException When some parameters are missing that are mandatory for the route Chris@0: * @throws InvalidParameterException When a parameter value for a placeholder is not correct because Chris@0: * it does not match the requirement Chris@0: */ Chris@17: protected function doGenerate($variables, $defaults, $requirements, $tokens, $parameters, $name, $referenceType, $hostTokens, array $requiredSchemes = []) Chris@0: { Chris@0: $variables = array_flip($variables); Chris@0: $mergedParams = array_replace($defaults, $this->context->getParameters(), $parameters); Chris@0: Chris@0: // all params must be given Chris@0: if ($diff = array_diff_key($variables, $mergedParams)) { Chris@0: throw new MissingMandatoryParametersException(sprintf('Some mandatory parameters are missing ("%s") to generate a URL for route "%s".', implode('", "', array_keys($diff)), $name)); Chris@0: } Chris@0: Chris@0: $url = ''; Chris@0: $optional = true; Chris@0: $message = 'Parameter "{parameter}" for route "{route}" must match "{expected}" ("{given}" given) to generate a corresponding URL.'; Chris@0: foreach ($tokens as $token) { Chris@0: if ('variable' === $token[0]) { Chris@18: if (!$optional || !\array_key_exists($token[3], $defaults) || null !== $mergedParams[$token[3]] && (string) $mergedParams[$token[3]] !== (string) $defaults[$token[3]]) { Chris@18: // check requirement (while ignoring look-around patterns) Chris@18: if (null !== $this->strictRequirements && !preg_match('#^'.preg_replace('/\(\?(?:=|<=|!|strictRequirements) { Chris@17: throw new InvalidParameterException(strtr($message, ['{parameter}' => $token[3], '{route}' => $name, '{expected}' => $token[2], '{given}' => $mergedParams[$token[3]]])); Chris@0: } Chris@0: Chris@0: if ($this->logger) { Chris@17: $this->logger->error($message, ['parameter' => $token[3], 'route' => $name, 'expected' => $token[2], 'given' => $mergedParams[$token[3]]]); Chris@0: } Chris@0: Chris@0: return; Chris@0: } Chris@0: Chris@0: $url = $token[1].$mergedParams[$token[3]].$url; Chris@0: $optional = false; Chris@0: } Chris@0: } else { Chris@0: // static text Chris@0: $url = $token[1].$url; Chris@0: $optional = false; Chris@0: } Chris@0: } Chris@0: Chris@0: if ('' === $url) { Chris@0: $url = '/'; Chris@0: } Chris@0: Chris@0: // the contexts base URL is already encoded (see Symfony\Component\HttpFoundation\Request) Chris@0: $url = strtr(rawurlencode($url), $this->decodedChars); Chris@0: Chris@0: // the path segments "." and ".." are interpreted as relative reference when resolving a URI; see http://tools.ietf.org/html/rfc3986#section-3.3 Chris@0: // so we need to encode them as they are not used for this purpose here Chris@0: // otherwise we would generate a URI that, when followed by a user agent (e.g. browser), does not match this route Chris@17: $url = strtr($url, ['/../' => '/%2E%2E/', '/./' => '/%2E/']); Chris@0: if ('/..' === substr($url, -3)) { Chris@0: $url = substr($url, 0, -2).'%2E%2E'; Chris@0: } elseif ('/.' === substr($url, -2)) { Chris@0: $url = substr($url, 0, -1).'%2E'; Chris@0: } Chris@0: Chris@0: $schemeAuthority = ''; Chris@14: $host = $this->context->getHost(); Chris@14: $scheme = $this->context->getScheme(); Chris@0: Chris@14: if ($requiredSchemes) { Chris@17: if (!\in_array($scheme, $requiredSchemes, true)) { Chris@14: $referenceType = self::ABSOLUTE_URL; Chris@14: $scheme = current($requiredSchemes); Chris@14: } Chris@14: } Chris@14: Chris@14: if ($hostTokens) { Chris@14: $routeHost = ''; Chris@14: foreach ($hostTokens as $token) { Chris@14: if ('variable' === $token[0]) { Chris@18: // check requirement (while ignoring look-around patterns) Chris@18: if (null !== $this->strictRequirements && !preg_match('#^'.preg_replace('/\(\?(?:=|<=|!|strictRequirements) { Chris@17: throw new InvalidParameterException(strtr($message, ['{parameter}' => $token[3], '{route}' => $name, '{expected}' => $token[2], '{given}' => $mergedParams[$token[3]]])); Chris@14: } Chris@14: Chris@14: if ($this->logger) { Chris@17: $this->logger->error($message, ['parameter' => $token[3], 'route' => $name, 'expected' => $token[2], 'given' => $mergedParams[$token[3]]]); Chris@14: } Chris@14: Chris@14: return; Chris@14: } Chris@14: Chris@14: $routeHost = $token[1].$mergedParams[$token[3]].$routeHost; Chris@14: } else { Chris@14: $routeHost = $token[1].$routeHost; Chris@0: } Chris@0: } Chris@0: Chris@14: if ($routeHost !== $host) { Chris@14: $host = $routeHost; Chris@14: if (self::ABSOLUTE_URL !== $referenceType) { Chris@14: $referenceType = self::NETWORK_PATH; Chris@0: } Chris@0: } Chris@14: } Chris@0: Chris@14: if ((self::ABSOLUTE_URL === $referenceType || self::NETWORK_PATH === $referenceType) && !empty($host)) { Chris@14: $port = ''; Chris@14: if ('http' === $scheme && 80 != $this->context->getHttpPort()) { Chris@14: $port = ':'.$this->context->getHttpPort(); Chris@14: } elseif ('https' === $scheme && 443 != $this->context->getHttpsPort()) { Chris@14: $port = ':'.$this->context->getHttpsPort(); Chris@14: } Chris@0: Chris@14: $schemeAuthority = self::NETWORK_PATH === $referenceType ? '//' : "$scheme://"; Chris@14: $schemeAuthority .= $host.$port; Chris@0: } Chris@0: Chris@0: if (self::RELATIVE_PATH === $referenceType) { Chris@0: $url = self::getRelativePath($this->context->getPathInfo(), $url); Chris@0: } else { Chris@0: $url = $schemeAuthority.$this->context->getBaseUrl().$url; Chris@0: } Chris@0: Chris@0: // add a query string if needed Chris@0: $extra = array_udiff_assoc(array_diff_key($parameters, $variables), $defaults, function ($a, $b) { Chris@0: return $a == $b ? 0 : 1; Chris@0: }); Chris@0: Chris@0: // extract fragment Chris@0: $fragment = ''; Chris@0: if (isset($defaults['_fragment'])) { Chris@0: $fragment = $defaults['_fragment']; Chris@0: } Chris@0: Chris@0: if (isset($extra['_fragment'])) { Chris@0: $fragment = $extra['_fragment']; Chris@0: unset($extra['_fragment']); Chris@0: } Chris@0: Chris@0: if ($extra && $query = http_build_query($extra, '', '&', PHP_QUERY_RFC3986)) { Chris@0: // "/" and "?" can be left decoded for better user experience, see Chris@0: // http://tools.ietf.org/html/rfc3986#section-3.4 Chris@17: $url .= '?'.strtr($query, ['%2F' => '/']); Chris@0: } Chris@0: Chris@0: if ('' !== $fragment) { Chris@17: $url .= '#'.strtr(rawurlencode($fragment), ['%2F' => '/', '%3F' => '?']); Chris@0: } Chris@0: Chris@0: return $url; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Returns the target path as relative reference from the base path. Chris@0: * Chris@0: * Only the URIs path component (no schema, host etc.) is relevant and must be given, starting with a slash. Chris@0: * Both paths must be absolute and not contain relative parts. Chris@0: * Relative URLs from one resource to another are useful when generating self-contained downloadable document archives. Chris@0: * Furthermore, they can be used to reduce the link size in documents. Chris@0: * Chris@0: * Example target paths, given a base path of "/a/b/c/d": Chris@0: * - "/a/b/c/d" -> "" Chris@0: * - "/a/b/c/" -> "./" Chris@0: * - "/a/b/" -> "../" Chris@0: * - "/a/b/c/other" -> "other" Chris@0: * - "/a/x/y" -> "../../x/y" Chris@0: * Chris@0: * @param string $basePath The base path Chris@0: * @param string $targetPath The target path Chris@0: * Chris@0: * @return string The relative target path Chris@0: */ Chris@0: public static function getRelativePath($basePath, $targetPath) Chris@0: { Chris@0: if ($basePath === $targetPath) { Chris@0: return ''; Chris@0: } Chris@0: Chris@0: $sourceDirs = explode('/', isset($basePath[0]) && '/' === $basePath[0] ? substr($basePath, 1) : $basePath); Chris@0: $targetDirs = explode('/', isset($targetPath[0]) && '/' === $targetPath[0] ? substr($targetPath, 1) : $targetPath); Chris@0: array_pop($sourceDirs); Chris@0: $targetFile = array_pop($targetDirs); Chris@0: Chris@0: foreach ($sourceDirs as $i => $dir) { Chris@0: if (isset($targetDirs[$i]) && $dir === $targetDirs[$i]) { Chris@0: unset($sourceDirs[$i], $targetDirs[$i]); Chris@0: } else { Chris@0: break; Chris@0: } Chris@0: } Chris@0: Chris@0: $targetDirs[] = $targetFile; Chris@17: $path = str_repeat('../', \count($sourceDirs)).implode('/', $targetDirs); Chris@0: Chris@0: // A reference to the same base directory or an empty subdirectory must be prefixed with "./". Chris@0: // This also applies to a segment with a colon character (e.g., "file:colon") that cannot be used Chris@0: // as the first segment of a relative-path reference, as it would be mistaken for a scheme name Chris@0: // (see http://tools.ietf.org/html/rfc3986#section-4.2). Chris@0: return '' === $path || '/' === $path[0] Chris@0: || false !== ($colonPos = strpos($path, ':')) && ($colonPos < ($slashPos = strpos($path, '/')) || false === $slashPos) Chris@0: ? "./$path" : $path; Chris@0: } Chris@0: }