Browse Source

Merge pull request #8767 from cakephp/asset-middleware

Add AssetMiddleware
José Lorenzo Rodríguez 10 years ago
parent
commit
d7e1ab398e

+ 186 - 0
src/Routing/Middleware/AssetMiddleware.php

@@ -0,0 +1,186 @@
+<?php
+/**
+ * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
+ * 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://cakephp.org CakePHP(tm) Project
+ * @since         3.3.0
+ * @license       http://www.opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Routing\Middleware;
+
+use Cake\Core\Plugin;
+use Cake\Filesystem\File;
+use Cake\Utility\Inflector;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Zend\Diactoros\Response;
+use Zend\Diactoros\Stream;
+
+/**
+ * Handles serving plugin assets in development mode.
+ *
+ * This should not be used in production environments as it
+ * has sub-optimal performance when compared to serving files
+ * with a real webserver.
+ */
+class AssetMiddleware
+{
+    /**
+     * The amount of time to cache the asset.
+     *
+     * @var string
+     */
+    protected $cacheTime = '+1 day';
+
+    /**
+     * A extension to content type mapping for plain text types.
+     *
+     * Because finfo doesn't give useful information for plain text types,
+     * we have to handle that here.
+     *
+     * @var array
+     */
+    protected $typeMap = [
+        'css' => 'text/css',
+        'json' => 'application/json',
+        'js' => 'application/javascript',
+        'ico' => 'image/x-icon',
+        'eot' => 'application/vnd.ms-fontobject',
+        'svg' => 'image/svg+xml',
+        'html' => 'text/html',
+        'rss' => 'application/rss+xml',
+        'xml' => 'application/xml',
+    ];
+
+    /**
+     * Constructor.
+     *
+     * @param array $options The options to use
+     */
+    public function __construct(array $options = [])
+    {
+        if (!empty($options['cacheTime'])) {
+            $this->cacheTime = $options['cacheTime'];
+        }
+        if (!empty($options['types'])) {
+            $this->typeMap = array_merge($this->typeMap, $options['types']);
+        }
+    }
+
+    /**
+     * Serve assets if the path matches one.
+     *
+     * @param \Psr\Http\Message\ServerRequestInterface $request The request.
+     * @param \Psr\Http\Message\ResponseInterface $response The response.
+     * @param callable $next Callback to invoke the next middleware.
+     * @return \Psr\Http\Message\ResponseInterface A response
+     */
+    public function __invoke($request, $response, $next)
+    {
+        $url = $request->getUri()->getPath();
+        if (strpos($url, '..') !== false || strpos($url, '.') === false) {
+            return $next($request, $response);
+        }
+
+        $assetFile = $this->_getAssetFile($url);
+        if ($assetFile === null || !file_exists($assetFile)) {
+            return $next($request, $response);
+        }
+
+        $file = new File($assetFile);
+        $modifiedTime = $file->lastChange();
+        if ($this->isNotModified($request, $file)) {
+            $headers = $response->getHeaders();
+            $headers['Last-Modified'] = date(DATE_RFC850, $modifiedTime);
+            return new Response('php://memory', 304, $headers);
+        }
+        return $this->deliverAsset($request, $response, $file);
+    }
+
+    /**
+     * Check the not modified header.
+     *
+     * @param \Psr\Http\Message\ServerRequestInterface $request The request to check.
+     * @param \Cake\Filesystem\File $file The file object to compare.
+     * @return bool
+     */
+    protected function isNotModified($request, $file)
+    {
+        $modifiedSince = $request->getHeaderLine('If-Modified-Since');
+        if (!$modifiedSince) {
+            return false;
+        }
+        return strtotime($modifiedSince) === $file->lastChange();
+    }
+
+    /**
+     * Builds asset file path based off url
+     *
+     * @param string $url Asset URL
+     * @return string Absolute path for asset file
+     */
+    protected function _getAssetFile($url)
+    {
+        $parts = explode('/', ltrim($url, '/'));
+        $pluginPart = [];
+        for ($i = 0; $i < 2; $i++) {
+            if (!isset($parts[$i])) {
+                break;
+            }
+            $pluginPart[] = Inflector::camelize($parts[$i]);
+            $plugin = implode('/', $pluginPart);
+            if ($plugin && Plugin::loaded($plugin)) {
+                $parts = array_slice($parts, $i + 1);
+                $fileFragment = implode(DIRECTORY_SEPARATOR, $parts);
+                $pluginWebroot = Plugin::path($plugin) . 'webroot' . DIRECTORY_SEPARATOR;
+                return $pluginWebroot . $fileFragment;
+            }
+        }
+        return '';
+    }
+
+    /**
+     * Sends an asset file to the client
+     *
+     * @param \Psr\Http\Message\ServerRequestInterface $request The request object to use.
+     * @param \Psr\Http\Message\ResponseInterface $response The response object to use.
+     * @param \Cake\Filesystem\File $file The file wrapper for the file.
+     * @return \Psr\Http\Message\ResponseInterface The response with the file & headers.
+     */
+    protected function deliverAsset(ServerRequestInterface $request, ResponseInterface $response, $file)
+    {
+        $contentType = $this->getType($file);
+        $modified = $file->lastChange();
+        $expire = strtotime($this->cacheTime);
+        $maxAge = $expire - time();
+
+        $stream = new Stream(fopen($file->path, 'rb'));
+        return $response->withBody($stream)
+            ->withHeader('Content-Type', $contentType)
+            ->withHeader('Cache-Control', 'public,max-age=' . $maxAge)
+            ->withHeader('Date', gmdate('D, j M Y G:i:s \G\M\T', time()))
+            ->withHeader('Last-Modified', gmdate('D, j M Y G:i:s \G\M\T', $modified))
+            ->withHeader('Expires', gmdate('D, j M Y G:i:s \G\M\T', $expire));
+    }
+
+    /**
+     * Return the type from a File object
+     *
+     * @param File $file The file from which you get the type
+     * @return string
+     */
+    protected function getType($file)
+    {
+        $extension = $file->ext();
+        if (isset($this->typeMap[$extension])) {
+            return $this->typeMap[$extension];
+        }
+        return $file->mime() ?: 'application/octet-stream';
+    }
+}

+ 236 - 0
tests/TestCase/Routing/Middleware/AssetMiddlewareTest.php

@@ -0,0 +1,236 @@
+<?php
+/**
+ * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
+ * 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://cakephp.org CakePHP(tm) Project
+ * @since         3.3.0
+ * @license       http://www.opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Test\TestCase\Routing\Middleware;
+
+use Cake\Core\Plugin;
+use Cake\Http\ServerRequestFactory;
+use Cake\Routing\Middleware\AssetMiddleware;
+use Cake\TestSuite\TestCase;
+use Zend\Diactoros\Request;
+use Zend\Diactoros\Response;
+
+/**
+ * Test for AssetMiddleware
+ */
+class AssetMiddlewareTest extends TestCase
+{
+    /**
+     * setup
+     *
+     * @return void
+     */
+    public function setUp()
+    {
+        parent::setUp();
+        Plugin::load('TestPlugin');
+        Plugin::load('Company/TestPluginThree');
+    }
+
+    /**
+     * test that the if modified since header generates 304 responses
+     *
+     * @return void
+     */
+    public function testCheckIfModifiedHeader()
+    {
+        $modified = filemtime(TEST_APP . 'Plugin/TestPlugin/webroot/root.js');
+        $request = ServerRequestFactory::fromGlobals([
+            'REQUEST_URI' => '/test_plugin/root.js',
+            'HTTP_IF_MODIFIED_SINCE' => date('D, j M Y G:i:s \G\M\T', $modified)
+        ]);
+        $response = new Response();
+        $next = function ($req, $res) {
+            return $res;
+        };
+        $middleware = new AssetMiddleware();
+        $res = $middleware($request, $response, $next);
+
+        $body = $res->getBody()->getContents();
+        $this->assertEquals('', $body);
+        $this->assertEquals(304, $res->getStatusCode());
+        $this->assertNotEmpty($res->getHeaderLine('Last-Modified'));
+    }
+
+    /**
+     * test missing plugin assets.
+     *
+     * @return void
+     */
+    public function testMissingPluginAsset()
+    {
+        $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => '/test_plugin/not_found.js']);
+        $response = new Response();
+        $next = function ($req, $res) {
+            return $res;
+        };
+
+        $middleware = new AssetMiddleware();
+        $res = $middleware($request, $response, $next);
+
+        $body = $res->getBody()->getContents();
+        $this->assertEquals('', $body);
+    }
+
+    /**
+     * Data provider for assets.
+     *
+     * @return array
+     */
+    public function assetProvider()
+    {
+        return [
+            // In plugin root.
+            [
+                '/test_plugin/root.js',
+                TEST_APP . 'Plugin/TestPlugin/webroot/root.js'
+            ],
+            // Subdirectory
+            [
+                '/test_plugin/js/alert.js',
+                TEST_APP . 'Plugin/TestPlugin/webroot/js/alert.js'
+            ],
+            // In path that matches the plugin name
+            [
+                '/test_plugin/js/test_plugin/test.js',
+                TEST_APP . 'Plugin/TestPlugin/webroot/js/test_plugin/test.js'
+            ],
+            // In vendored plugin
+            [
+                '/company/test_plugin_three/css/company.css',
+                TEST_APP . 'Plugin/Company/TestPluginThree/webroot/css/company.css'
+            ],
+        ];
+    }
+
+    /**
+     * Test assets in a plugin.
+     *
+     * @dataProvider assetProvider
+     */
+    public function testPluginAsset($url, $expectedFile)
+    {
+        $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => $url]);
+        $response = new Response();
+        $next = function ($req, $res) {
+            return $res;
+        };
+
+        $middleware = new AssetMiddleware();
+        $res = $middleware($request, $response, $next);
+
+        $body = $res->getBody()->getContents();
+        $this->assertEquals(file_get_contents($expectedFile), $body);
+    }
+
+    /**
+     * Test headers with plugin assets
+     *
+     * @return void
+     */
+    public function testPluginAssetHeaders()
+    {
+        $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => '/test_plugin/root.js']);
+        $response = new Response();
+        $next = function ($req, $res) {
+            return $res;
+        };
+
+        $modified = filemtime(TEST_APP . 'Plugin/TestPlugin/webroot/root.js');
+        $expires = strtotime('+4 hours');
+        $time = time();
+
+        $middleware = new AssetMiddleware(['cacheTime' => '+4 hours']);
+        $res = $middleware($request, $response, $next);
+
+        $this->assertEquals(
+            'application/javascript',
+            $res->getHeaderLine('Content-Type')
+        );
+        $this->assertEquals(
+            gmdate('D, j M Y G:i:s ', $time) . 'GMT',
+            $res->getHeaderLine('Date')
+        );
+        $this->assertEquals(
+            'public,max-age=' . ($expires - $time),
+            $res->getHeaderLine('Cache-Control')
+        );
+        $this->assertEquals(
+            gmdate('D, j M Y G:i:s ', $modified) . 'GMT',
+            $res->getHeaderLine('Last-Modified')
+        );
+        $this->assertEquals(
+            gmdate('D, j M Y G:i:s ', $expires) . 'GMT',
+            $res->getHeaderLine('Expires')
+        );
+    }
+
+    /**
+     * Test that content-types can be injected
+     *
+     * @return void
+     */
+    public function testCustomFileTypes()
+    {
+        $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => '/test_plugin/root.js']);
+        $response = new Response();
+        $next = function ($req, $res) {
+            return $res;
+        };
+
+        $middleware = new AssetMiddleware(['types' => ['js' => 'custom/stuff']]);
+        $res = $middleware($request, $response, $next);
+
+        $this->assertEquals(
+            'custom/stuff',
+            $res->getHeaderLine('Content-Type')
+        );
+    }
+
+    /**
+     * Test that // results in a 404
+     *
+     * @return void
+     */
+    public function test404OnDoubleSlash()
+    {
+        $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => '//index.php']);
+        $response = new Response();
+        $next = function ($req, $res) {
+            return $res;
+        };
+
+        $middleware = new AssetMiddleware();
+        $res = $middleware($request, $response, $next);
+        $this->assertEmpty($res->getBody()->getContents());
+    }
+
+    /**
+     * Test that .. results in a 404
+     *
+     * @return void
+     */
+    public function test404OnDoubleDot()
+    {
+        $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => '/test_plugin/../webroot/root.js']);
+        $response = new Response();
+        $next = function ($req, $res) {
+            return $res;
+        };
+
+        $middleware = new AssetMiddleware();
+        $res = $middleware($request, $response, $next);
+        $this->assertEmpty($res->getBody()->getContents());
+    }
+}

+ 0 - 0
tests/test_app/Plugin/TestPlugin/webroot/js/alert.js