From 3e32f7a06393f437f0364cac7bf26a9534c6f4e2 Mon Sep 17 00:00:00 2001 From: Matthew Weier O'Phinney Date: Tue, 25 Aug 2015 20:36:51 -0500 Subject: [PATCH 1/4] Initial template namespace implementation - Tests and assets created for each template type. - Plates ran out of the box. - Twig updated to use namespacing notation. - zend-view still in progress --- src/Template/Twig.php | 28 ++++++++++++++++++++++++- test/Template/PlatesTest.php | 14 +++++++++++++ test/Template/TestAsset/test/test.html | 3 +++ test/Template/TestAsset/test/test.js | 3 +++ test/Template/TestAsset/test/test.php | 3 +++ test/Template/TestAsset/test/test.phtml | 3 +++ test/Template/TwigTest.php | 28 +++++++++++++++++++++++++ test/Template/ZendViewTest.php | 14 +++++++++++++ 8 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 test/Template/TestAsset/test/test.html create mode 100644 test/Template/TestAsset/test/test.js create mode 100644 test/Template/TestAsset/test/test.php create mode 100644 test/Template/TestAsset/test/test.phtml diff --git a/src/Template/Twig.php b/src/Template/Twig.php index 9a9cb1f4..1aa7ebd3 100644 --- a/src/Template/Twig.php +++ b/src/Template/Twig.php @@ -20,6 +20,11 @@ class Twig implements TemplateInterface { use ArrayParametersTrait; + /** + * @var string + */ + private $suffix; + /** * @var TwigFilesystem */ @@ -35,7 +40,7 @@ class Twig implements TemplateInterface * * @param TwigEnvironment $template */ - public function __construct(TwigEnvironment $template = null) + public function __construct(TwigEnvironment $template = null, $suffix = 'html') { if (null === $template) { $template = $this->createTemplate($this->getDefaultLoader()); @@ -50,6 +55,7 @@ public function __construct(TwigEnvironment $template = null) $this->template = $template; $this->twigLoader = $loader; + $this->suffix = is_string($suffix) ? $suffix : 'html'; } /** @@ -82,6 +88,7 @@ private function getDefaultLoader() */ public function render($name, $params = []) { + $name = $this->normalizeTemplate($name); $params = $this->normalizeParams($params); return $this->template->render($name, $params); } @@ -115,4 +122,23 @@ public function getPaths() } return $paths; } + + /** + * Normalize namespaced template. + * + * Normalizes templates in the format "namespace::template" to + * "@namespace/template". + * + * @param string $template + * @return string + */ + public function normalizeTemplate($template) + { + $template = preg_replace('#^([^:]+)::(.*)$#', '@$1/$2', $template); + if (! preg_match('#\.[a-z]+$#i', $template)) { + return sprintf('%s.%s', $template, $this->suffix); + } + + return $template; + } } diff --git a/test/Template/PlatesTest.php b/test/Template/PlatesTest.php index 6501634d..3a7e320a 100644 --- a/test/Template/PlatesTest.php +++ b/test/Template/PlatesTest.php @@ -160,4 +160,18 @@ public function testCanRenderWithParameterObjects($params, $search) $content = str_replace('e($name)?>', $search, $content); $this->assertEquals($content, $result); } + + /** + * @group namespacing + */ + public function testProperlyResolvesNamespacedTemplate() + { + $template = new PlatesTemplate(); + $template->addPath(__DIR__ . '/TestAsset/test', 'test'); + + $expected = file_get_contents(__DIR__ . '/TestAsset/test/test.php'); + $test = $template->render('test::test'); + + $this->assertSame($expected, $test); + } } diff --git a/test/Template/TestAsset/test/test.html b/test/Template/TestAsset/test/test.html new file mode 100644 index 00000000..cb427324 --- /dev/null +++ b/test/Template/TestAsset/test/test.html @@ -0,0 +1,3 @@ +

Twig Test Namespace

+ +

This template is from the test namespace.

diff --git a/test/Template/TestAsset/test/test.js b/test/Template/TestAsset/test/test.js new file mode 100644 index 00000000..577eedc7 --- /dev/null +++ b/test/Template/TestAsset/test/test.js @@ -0,0 +1,3 @@ +{ + "test": "twig" +} diff --git a/test/Template/TestAsset/test/test.php b/test/Template/TestAsset/test/test.php new file mode 100644 index 00000000..c485b6a5 --- /dev/null +++ b/test/Template/TestAsset/test/test.php @@ -0,0 +1,3 @@ +

Plates Test Namespace

+ +

This template is from the test namespace.

diff --git a/test/Template/TestAsset/test/test.phtml b/test/Template/TestAsset/test/test.phtml new file mode 100644 index 00000000..10290aee --- /dev/null +++ b/test/Template/TestAsset/test/test.phtml @@ -0,0 +1,3 @@ +

zend-view Test Namespace

+ +

This template is from the test namespace.

diff --git a/test/Template/TwigTest.php b/test/Template/TwigTest.php index 998fcb36..4c306a42 100644 --- a/test/Template/TwigTest.php +++ b/test/Template/TwigTest.php @@ -143,4 +143,32 @@ public function testCanRenderWithParameterObjects($params, $search) $content = str_replace('{{ name }}', $search, $content); $this->assertEquals($content, $result); } + + /** + * @group namespacing + */ + public function testProperlyResolvesNamespacedTemplate() + { + $template = new TwigTemplate(); + $template->addPath(__DIR__ . '/TestAsset/test', 'test'); + + $expected = file_get_contents(__DIR__ . '/TestAsset/test/test.html'); + $test = $template->render('test::test'); + + $this->assertSame($expected, $test); + } + + /** + * @group namespacing + */ + public function testResolvesNamespacedTemplateWithSuffix() + { + $template = new TwigTemplate(); + $template->addPath(__DIR__ . '/TestAsset/test', 'test'); + + $expected = file_get_contents(__DIR__ . '/TestAsset/test/test.js'); + $test = $template->render('test::test.js'); + + $this->assertSame($expected, $test); + } } diff --git a/test/Template/ZendViewTest.php b/test/Template/ZendViewTest.php index 95498af5..a5db0999 100644 --- a/test/Template/ZendViewTest.php +++ b/test/Template/ZendViewTest.php @@ -224,4 +224,18 @@ public function testCanPassViewModelForLayoutParameterWhenRendering() $this->assertContains($content, $result); $this->assertContains('ALTERNATE LAYOUT PAGE', $result); } + + /** + * @group namespacing + */ + public function testProperlyResolvesNamespacedTemplate() + { + $template = new ZendView(); + $template->addPath(__DIR__ . '/TestAsset/test', 'test'); + + $expected = file_get_contents(__DIR__ . '/TestAsset/test/test.phtml'); + $test = $template->render('test::test'); + + $this->assertSame($expected, $test); + } } From cc6709887c7221bd4832220526cc339f785971f5 Mon Sep 17 00:00:00 2001 From: Matthew Weier O'Phinney Date: Tue, 25 Aug 2015 22:47:29 -0500 Subject: [PATCH 2/4] Implement template namespaces for ZendView adapter - Creates a NamespacedPathStackResolver implementation, which extends the basic TemplatePathStack in order to ensure things like LFI security and template streams also work. It segregates paths by namespace, and resolves based on the namespace provided with the template, falling back to the default namespace if no match is found. - The ZendView adapter now always composes an AggregateResolver. If an alternate resolver is found, it is pushed into an AggregateResolver. Additionally, a NamespacedPathStackResolver is always injected at priority 0 (lower than default) so that we can push paths to it. --- src/Template/ZendView.php | 134 ++++++++--- .../ZendView/NamespacedPathStackResolver.php | 226 ++++++++++++++++++ 2 files changed, 326 insertions(+), 34 deletions(-) create mode 100644 src/Template/ZendView/NamespacedPathStackResolver.php diff --git a/src/Template/ZendView.php b/src/Template/ZendView.php index 4e03e0bf..9d6b227f 100644 --- a/src/Template/ZendView.php +++ b/src/Template/ZendView.php @@ -13,12 +13,19 @@ use Zend\View\Model\ViewModel; use Zend\View\Renderer\RendererInterface; use Zend\View\Renderer\PhpRenderer; +use Zend\View\Resolver\AggregateResolver; use Zend\View\Resolver\ResolverInterface; -use Zend\View\Resolver\TemplatePathStack; use Zend\Expressive\Exception; /** * Template implementation bridging zendframework/zend-view. + * + * This implementation provides additional capabilities. + * + * First, it always ensures the resolver is an AggregateResolver, pushing any + * non-Aggregate into a new AggregateResolver instance. Additionally, it always + * registers a ZendView\NamespacedPathStackResolver at priority 0 (lower than + * default) in the Aggregate to ensure we can add and resolve namespaced paths. */ class ZendView implements TemplateInterface { @@ -40,7 +47,7 @@ class ZendView implements TemplateInterface private $renderer; /** - * @var ResolverInterface + * @var ZendView\NamespacedPathStackResolver */ private $resolver; @@ -67,9 +74,17 @@ public function __construct(RendererInterface $renderer = null, $layout = null) { if (null === $renderer) { $renderer = $this->createRenderer(); + $resolver = $renderer->resolver(); + } else { + $resolver = $renderer->resolver(); + if (! $resolver instanceof AggregateResolver) { + $aggregate = $this->getDefaultResolver(); + $aggregate->attach($resolver); + $resolver = $aggregate; + } elseif (! $this->hasNamespacedResolver($resolver)) { + $this->injectNamespacedResolver($resolver); + } } - $this->renderer = $renderer; - $this->resolver = $renderer->resolver(); if ($layout && is_string($layout)) { $model = new ViewModel(); @@ -85,29 +100,9 @@ public function __construct(RendererInterface $renderer = null, $layout = null) )); } - $this->layout = $layout; - } - - /** - * Returns a PhpRenderer object - * - * @return PhpRenderer - */ - private function createRenderer() - { - $render = new PhpRenderer(); - $render->setResolver($this->getDefaultResolver()); - return $render; - } - - /** - * Get the default resolver - * - * @return TemplatePathStack - */ - private function getDefaultResolver() - { - return new TemplatePathStack(); + $this->renderer = $renderer; + $this->resolver = $this->getNamespacedResolver($resolver); + $this->layout = $layout; } /** @@ -143,10 +138,7 @@ public function render($name, $params = []) */ public function addPath($path, $namespace = null) { - $this->resolver->addPath($path); - - // Normalize the path to be compliant with the TemplatePathStack - $this->paths[TemplatePathStack::normalizePath($path)] = $namespace; + $this->resolver->addPath($path, $namespace); } /** @@ -157,10 +149,20 @@ public function addPath($path, $namespace = null) public function getPaths() { $paths = []; - foreach ($this->resolver->getPaths() as $path) { - $namespace = array_key_exists($path, $this->paths) ? $this->paths[$path] : null; - $paths[] = new TemplatePath($path, $namespace); + + foreach ($this->resolver->getPaths() as $namespace => $namespacedPaths) { + if ($namespace === ZendView\NamespacedPathStackResolver::DEFAULT_NAMESPACE + || empty($namespace) + || is_int($namespace) + ) { + $namespace = null; + } + + foreach ($namespacedPaths as $path) { + $paths[] = new TemplatePath($path, $namespace); + } } + return $paths; } @@ -246,4 +248,68 @@ private function renderModel(ModelInterface $model, RendererInterface $renderer) return $renderer->render($model); } + + /** + * Returns a PhpRenderer object + * + * @return PhpRenderer + */ + private function createRenderer() + { + $renderer = new PhpRenderer(); + $renderer->setResolver($this->getDefaultResolver()); + return $renderer; + } + + /** + * Get the default resolver + * + * @return ZendView\NamespacedPathStackResolver + */ + private function getDefaultResolver() + { + $resolver = new AggregateResolver(); + $this->injectNamespacedResolver($resolver); + return $resolver; + } + + /** + * Attaches a new ZendView\NamespacedPathStackResolver to the AggregateResolver + * + * A priority of 0 is used, to ensure it is the last queried. + * + * @param AggregateResolver $aggregate + */ + private function injectNamespacedResolver(AggregateResolver $aggregate) + { + $aggregate->attach(new ZendView\NamespacedPathStackResolver(), 0); + } + + /** + * @param AggregateResolver $aggregate + * @return bool + */ + private function hasNamespacedResolver(AggregateResolver $aggregate) + { + foreach ($aggregate as $resolver) { + if ($resolver instanceof ZendView\NamespacedPathStackResolver) { + return true; + } + } + + return false; + } + + /** + * @param AggregateResolver $aggregate + * @return null|ZendView\NamespacedPathStackResolver + */ + private function getNamespacedResolver(AggregateResolver $aggregate) + { + foreach ($aggregate as $resolver) { + if ($resolver instanceof ZendView\NamespacedPathStackResolver) { + return $resolver; + } + } + } } diff --git a/src/Template/ZendView/NamespacedPathStackResolver.php b/src/Template/ZendView/NamespacedPathStackResolver.php new file mode 100644 index 00000000..f546e446 --- /dev/null +++ b/src/Template/ZendView/NamespacedPathStackResolver.php @@ -0,0 +1,226 @@ +useViewStream = (bool) ini_get('short_open_tag'); + if ($this->useViewStream) { + if (!in_array('zend.view', stream_get_wrappers())) { + stream_wrapper_register('zend.view', 'Zend\View\Stream'); + } + } + + if (null !== $options) { + $this->setOptions($options); + } + } + + /** + * Add a path to the stack with the given namespace. + * + * @param string $path + * @param string $namespace + * @throws ViewException\InvalidArgumentException for an invalid path + * @throws ViewException\InvalidArgumentException for an invalid namespace + */ + public function addPath($path, $namespace = self::DEFAULT_NAMESPACE) + { + if (! is_string($path)) { + throw new ViewException\InvalidArgumentException(sprintf( + 'Invalid path provided; expected a string, received %s', + gettype($path) + )); + } + + if (null === $namespace) { + $namespace = self::DEFAULT_NAMESPACE; + } + + if (! is_string($namespace) || empty($namespace)) { + throw new ViewException\InvalidArgumentException( + 'Invalid namespace provided; must be a non-empty string' + ); + } + + if (! array_key_exists($namespace, $this->paths)) { + $this->paths[$namespace] = new SplStack(); + } + + $this->paths[$namespace]->push(static::normalizePath($path)); + } + + /** + * Add many paths to the stack at once. + * + * @param array $paths + */ + public function addPaths(array $paths) + { + foreach ($paths as $namespace => $path) { + if (! is_string($namespace)) { + $namespace = self::DEFAULT_NAMESPACE; + } + + $this->addPath($path, $namespace); + } + } + + /** + * Overwrite all existing paths with the provided paths. + * + * @param array|Traversable $paths + * @throws ViewException\InvalidArgumentException for invalid path types. + */ + public function setPaths($paths) + { + if ($paths instanceof Traversable) { + $paths = iterator_to_array($paths); + } + + if (! is_array($paths)) { + throw new ViewException\InvalidArgumentException(sprintf( + 'Invalid paths provided; must be an array or Traversable, received %s', + (is_object($paths) ? get_class($paths) : gettype($paths)) + )); + } + + $this->clearPaths(); + $this->addPaths($paths); + } + + /** + * Clear all paths. + */ + public function clearPaths() + { + $this->paths = []; + } + + /** + * Retrieve the filesystem path to a view script + * + * @param string $name + * @param null|RendererInterface $renderer + * @return string + * @throws Exception\DomainException + */ + public function resolve($name, RendererInterface $renderer = null) + { + $namespace = self::DEFAULT_NAMESPACE; + $template = $name; + if (preg_match('#^(?P[^:]+)::(?P