diff --git a/src/View/Helper/UrlHelper.php b/src/View/Helper/UrlHelper.php new file mode 100644 index 00000000000..fc364436f0b --- /dev/null +++ b/src/View/Helper/UrlHelper.php @@ -0,0 +1,185 @@ +url($path, !empty($options['fullBase'])); + } + if (strpos($path, '://') !== false) { + return $path; + } + if (!array_key_exists('plugin', $options) || $options['plugin'] !== false) { + list($plugin, $path) = $this->_View->pluginSplit($path, false); + } + if (!empty($options['pathPrefix']) && $path[0] !== '/') { + $path = $options['pathPrefix'] . $path; + } + if ( + !empty($options['ext']) && + strpos($path, '?') === false && + substr($path, -strlen($options['ext'])) !== $options['ext'] + ) { + $path .= $options['ext']; + } + if (preg_match('|^([a-z0-9]+:)?//|', $path)) { + return $path; + } + if (isset($plugin)) { + $path = Inflector::underscore($plugin) . '/' . $path; + } + $path = $this->_encodeUrl($this->assetTimestamp($this->webroot($path))); + + if (!empty($options['fullBase'])) { + $path = rtrim(Router::fullBaseUrl(), '/') . '/' . ltrim($path, '/'); + } + return $path; + } + +/** + * Encodes a URL for use in HTML attributes. + * + * @param string $url The URL to encode. + * @return string The URL encoded for both URL & HTML contexts. + */ + protected function _encodeUrl($url) { + $path = parse_url($url, PHP_URL_PATH); + $parts = array_map('rawurldecode', explode('/', $path)); + $parts = array_map('rawurlencode', $parts); + $encoded = implode('/', $parts); + return h(str_replace($path, $encoded, $url)); + } + +/** + * Adds a timestamp to a file based resource based on the value of `Asset.timestamp` in + * Configure. If Asset.timestamp is true and debug is true, or Asset.timestamp === 'force' + * a timestamp will be added. + * + * @param string $path The file path to timestamp, the path must be inside WWW_ROOT + * @return string Path with a timestamp added, or not. + */ + public function assetTimestamp($path) { + $stamp = Configure::read('Asset.timestamp'); + $timestampEnabled = $stamp === 'force' || ($stamp === true && Configure::read('debug')); + if ($timestampEnabled && strpos($path, '?') === false) { + $filepath = preg_replace( + '/^' . preg_quote($this->request->webroot, '/') . '/', + '', + urldecode($path) + ); + $webrootPath = WWW_ROOT . str_replace('/', DS, $filepath); + if (file_exists($webrootPath)) { + //@codingStandardsIgnoreStart + return $path . '?' . @filemtime($webrootPath); + //@codingStandardsIgnoreEnd + } + $segments = explode('/', ltrim($filepath, '/')); + $plugin = Inflector::camelize($segments[0]); + if (Plugin::loaded($plugin)) { + unset($segments[0]); + $pluginPath = Plugin::path($plugin) . 'webroot' . DS . implode(DS, $segments); + //@codingStandardsIgnoreStart + return $path . '?' . @filemtime($pluginPath); + //@codingStandardsIgnoreEnd + } + } + return $path; + } + +/** + * Checks if a file exists when theme is used, if no file is found default location is returned + * + * @param string $file The file to create a webroot path to. + * @return string Web accessible path to file. + */ + public function webroot($file) { + $asset = explode('?', $file); + $asset[1] = isset($asset[1]) ? '?' . $asset[1] : null; + $webPath = $this->request->webroot . $asset[0]; + $file = $asset[0]; + + if (!empty($this->theme)) { + $file = trim($file, '/'); + $theme = Inflector::underscore($this->theme) . '/'; + + if (DS === '\\') { + $file = str_replace('/', '\\', $file); + } + + if (file_exists(Configure::read('App.www_root') . $theme . $file)) { + $webPath = $this->request->webroot . $theme . $asset[0]; + } else { + $themePath = Plugin::path($this->theme); + $path = $themePath . 'webroot/' . $file; + if (file_exists($path)) { + $webPath = $this->request->webroot . $theme . $asset[0]; + } + } + } + if (strpos($webPath, '//') !== false) { + return str_replace('//', '/', $webPath . $asset[1]); + } + return $webPath . $asset[1]; + } + +/** + * Event listeners. + * + * @return array + */ + public function implementedEvents() { + return []; + } + +} diff --git a/tests/TestCase/View/Helper/UrlHelperTest.php b/tests/TestCase/View/Helper/UrlHelperTest.php new file mode 100644 index 00000000000..fdf18301d02 --- /dev/null +++ b/tests/TestCase/View/Helper/UrlHelperTest.php @@ -0,0 +1,286 @@ + + * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org) + * + * Licensed under The MIT License + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice + * + * @copyright Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org) + * @link http://book.cakephp.org/2.0/en/development/testing.html CakePHP(tm) Tests + * @since 3.0.0 + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ +namespace Cake\Test\TestCase\View; + +use Cake\Core\Configure; +use Cake\Core\Plugin; +use Cake\Network\Request; +use Cake\Routing\Router; +use Cake\TestSuite\TestCase; +use Cake\View\Helper\UrlHelper; +use Cake\View\View; + +/** + * HelperTest class + * + */ +class HelperTest extends TestCase { + +/** + * setUp method + * + * @return void + */ + public function setUp() { + parent::setUp(); + + Router::reload(); + $this->View = new View(); + $this->Helper = new UrlHelper($this->View); + $this->Helper->request = new Request(); + + Configure::write('App.namespace', 'TestApp'); + Plugin::load(['TestTheme']); + } + +/** + * tearDown method + * + * @return void + */ + public function tearDown() { + parent::tearDown(); + Configure::delete('Asset'); + + Plugin::unload(); + unset($this->Helper, $this->View); + } + +/** + * Ensure HTML escaping of URL params. So link addresses are valid and not exploited + * + * @return void + */ + public function testUrlConversion() { + Router::connect('/:controller/:action/*'); + + $result = $this->Helper->url('/controller/action/1'); + $this->assertEquals('/controller/action/1', $result); + + $result = $this->Helper->url('/controller/action/1?one=1&two=2'); + $this->assertEquals('/controller/action/1?one=1&two=2', $result); + + $result = $this->Helper->url(array('controller' => 'posts', 'action' => 'index', 'page' => '1" onclick="alert(\'XSS\');"')); + $this->assertEquals("/posts/index?page=1%22+onclick%3D%22alert%28%27XSS%27%29%3B%22", $result); + + $result = $this->Helper->url('/controller/action/1/param:this+one+more'); + $this->assertEquals('/controller/action/1/param:this+one+more', $result); + + $result = $this->Helper->url('/controller/action/1/param:this%20one%20more'); + $this->assertEquals('/controller/action/1/param:this%20one%20more', $result); + + $result = $this->Helper->url('/controller/action/1/param:%7Baround%20here%7D%5Bthings%5D%5Bare%5D%24%24'); + $this->assertEquals('/controller/action/1/param:%7Baround%20here%7D%5Bthings%5D%5Bare%5D%24%24', $result); + + $result = $this->Helper->url(array( + 'controller' => 'posts', 'action' => 'index', 'param' => '%7Baround%20here%7D%5Bthings%5D%5Bare%5D%24%24' + )); + $this->assertEquals("/posts/index?param=%257Baround%2520here%257D%255Bthings%255D%255Bare%255D%2524%2524", $result); + + $result = $this->Helper->url(array( + 'controller' => 'posts', 'action' => 'index', 'page' => '1', + '?' => array('one' => 'value', 'two' => 'value', 'three' => 'purple') + )); + $this->assertEquals("/posts/index?page=1&one=value&two=value&three=purple", $result); + } + +/** + * test assetTimestamp application + * + * @return void + */ + public function testAssetTimestamp() { + Configure::write('Foo.bar', 'test'); + Configure::write('Asset.timestamp', false); + $result = $this->Helper->assetTimestamp(Configure::read('App.cssBaseUrl') . 'cake.generic.css'); + $this->assertEquals(Configure::read('App.cssBaseUrl') . 'cake.generic.css', $result); + + Configure::write('Asset.timestamp', true); + Configure::write('debug', false); + + $result = $this->Helper->assetTimestamp('/%3Cb%3E/cake.generic.css'); + $this->assertEquals('/%3Cb%3E/cake.generic.css', $result); + + $result = $this->Helper->assetTimestamp(Configure::read('App.cssBaseUrl') . 'cake.generic.css'); + $this->assertEquals(Configure::read('App.cssBaseUrl') . 'cake.generic.css', $result); + + Configure::write('Asset.timestamp', true); + Configure::write('debug', true); + $result = $this->Helper->assetTimestamp(Configure::read('App.cssBaseUrl') . 'cake.generic.css'); + $this->assertRegExp('/' . preg_quote(Configure::read('App.cssBaseUrl') . 'cake.generic.css?', '/') . '[0-9]+/', $result); + + Configure::write('Asset.timestamp', 'force'); + Configure::write('debug', false); + $result = $this->Helper->assetTimestamp(Configure::read('App.cssBaseUrl') . 'cake.generic.css'); + $this->assertRegExp('/' . preg_quote(Configure::read('App.cssBaseUrl') . 'cake.generic.css?', '/') . '[0-9]+/', $result); + + $result = $this->Helper->assetTimestamp(Configure::read('App.cssBaseUrl') . 'cake.generic.css?someparam'); + $this->assertEquals(Configure::read('App.cssBaseUrl') . 'cake.generic.css?someparam', $result); + + $this->Helper->request->webroot = '/some/dir/'; + $result = $this->Helper->assetTimestamp('/some/dir/' . Configure::read('App.cssBaseUrl') . 'cake.generic.css'); + $this->assertRegExp('/' . preg_quote(Configure::read('App.cssBaseUrl') . 'cake.generic.css?', '/') . '[0-9]+/', $result); + } + +/** + * test assetUrl application + * + * @return void + */ + public function testAssetUrl() { + Router::connect('/:controller/:action/*'); + + $this->Helper->webroot = ''; + $result = $this->Helper->assetUrl(array( + 'controller' => 'js', + 'action' => 'post', + '_ext' => 'js' + ), + array('fullBase' => true) + ); + $this->assertEquals(Router::fullBaseUrl() . '/js/post.js', $result); + + $result = $this->Helper->assetUrl('foo.jpg', array('pathPrefix' => 'img/')); + $this->assertEquals('img/foo.jpg', $result); + + $result = $this->Helper->assetUrl('foo.jpg', array('fullBase' => true)); + $this->assertEquals(Router::fullBaseUrl() . '/foo.jpg', $result); + + $result = $this->Helper->assetUrl('style', array('ext' => '.css')); + $this->assertEquals('style.css', $result); + + $result = $this->Helper->assetUrl('dir/sub dir/my image', array('ext' => '.jpg')); + $this->assertEquals('dir/sub%20dir/my%20image.jpg', $result); + + $result = $this->Helper->assetUrl('foo.jpg?one=two&three=four'); + $this->assertEquals('foo.jpg?one=two&three=four', $result); + + $result = $this->Helper->assetUrl('dir/big+tall/image', array('ext' => '.jpg')); + $this->assertEquals('dir/big%2Btall/image.jpg', $result); + } + +/** + * Test assetUrl with no rewriting. + * + * @return void + */ + public function testAssetUrlNoRewrite() { + $this->Helper->request->addPaths(array( + 'base' => '/cake_dev/index.php', + 'webroot' => '/cake_dev/app/webroot/', + 'here' => '/cake_dev/index.php/tasks', + )); + $result = $this->Helper->assetUrl('img/cake.icon.png', array('fullBase' => true)); + $expected = Configure::read('App.fullBaseUrl') . '/cake_dev/app/webroot/img/cake.icon.png'; + $this->assertEquals($expected, $result); + } + +/** + * Test assetUrl with plugins. + * + * @return void + */ + public function testAssetUrlPlugin() { + $this->Helper->webroot = ''; + Plugin::load('TestPlugin'); + + $result = $this->Helper->assetUrl('TestPlugin.style', array('ext' => '.css')); + $this->assertEquals('test_plugin/style.css', $result); + + $result = $this->Helper->assetUrl('TestPlugin.style', array('ext' => '.css', 'plugin' => false)); + $this->assertEquals('TestPlugin.style.css', $result); + + Plugin::unload('TestPlugin'); + } + +/** + * test assetUrl and Asset.timestamp = force + * + * @return void + */ + public function testAssetUrlTimestampForce() { + $this->Helper->webroot = ''; + Configure::write('Asset.timestamp', 'force'); + + $result = $this->Helper->assetUrl('cake.generic.css', array('pathPrefix' => Configure::read('App.cssBaseUrl'))); + $this->assertRegExp('/' . preg_quote(Configure::read('App.cssBaseUrl') . 'cake.generic.css?', '/') . '[0-9]+/', $result); + } + +/** + * test assetTimestamp with plugins and themes + * + * @return void + */ + public function testAssetTimestampPluginsAndThemes() { + Configure::write('Asset.timestamp', 'force'); + Plugin::load(array('TestPlugin')); + + $result = $this->Helper->assetTimestamp('/test_plugin/css/test_plugin_asset.css'); + $this->assertRegExp('#/test_plugin/css/test_plugin_asset.css\?[0-9]+$#', $result, 'Missing timestamp plugin'); + + $result = $this->Helper->assetTimestamp('/test_plugin/css/i_dont_exist.css'); + $this->assertRegExp('#/test_plugin/css/i_dont_exist.css\?$#', $result, 'No error on missing file'); + + $result = $this->Helper->assetTimestamp('/test_theme/js/theme.js'); + $this->assertRegExp('#/test_theme/js/theme.js\?[0-9]+$#', $result, 'Missing timestamp theme'); + + $result = $this->Helper->assetTimestamp('/test_theme/js/non_existant.js'); + $this->assertRegExp('#/test_theme/js/non_existant.js\?$#', $result, 'No error on missing file'); + } + +/** + * Test generating paths with webroot(). + * + * @return void + */ + public function testWebrootPaths() { + $this->Helper->request->webroot = '/'; + $result = $this->Helper->webroot('/img/cake.power.gif'); + $expected = '/img/cake.power.gif'; + $this->assertEquals($expected, $result); + + $this->Helper->theme = 'TestTheme'; + + $result = $this->Helper->webroot('/img/cake.power.gif'); + $expected = '/test_theme/img/cake.power.gif'; + $this->assertEquals($expected, $result); + + $result = $this->Helper->webroot('/img/test.jpg'); + $expected = '/test_theme/img/test.jpg'; + $this->assertEquals($expected, $result); + + $webRoot = Configure::read('App.www_root'); + Configure::write('App.www_root', TEST_APP . 'TestApp/webroot/'); + + $result = $this->Helper->webroot('/img/cake.power.gif'); + $expected = '/test_theme/img/cake.power.gif'; + $this->assertEquals($expected, $result); + + $result = $this->Helper->webroot('/img/test.jpg'); + $expected = '/test_theme/img/test.jpg'; + $this->assertEquals($expected, $result); + + $result = $this->Helper->webroot('/img/cake.icon.gif'); + $expected = '/img/cake.icon.gif'; + $this->assertEquals($expected, $result); + + $result = $this->Helper->webroot('/img/cake.icon.gif?some=param'); + $expected = '/img/cake.icon.gif?some=param'; + $this->assertEquals($expected, $result); + + Configure::write('App.www_root', $webRoot); + } + +}