From e023d9081f36a3b191b998847f977252aefe9967 Mon Sep 17 00:00:00 2001 From: Lyrkan Date: Thu, 14 Feb 2019 18:59:50 +0100 Subject: [PATCH] Add support for integrity hashes --- README.md | 4 + src/Asset/EntrypointLookup.php | 17 +++- src/Asset/IntegrityDataProviderInterface.php | 20 +++++ src/Asset/TagRenderer.php | 77 +++++++++++++++++-- src/DependencyInjection/Configuration.php | 4 + .../WebpackEncoreExtension.php | 2 + .../IntegrityDataNotFoundException.php | 14 ++++ src/Resources/config/services.xml | 1 + tests/Asset/EntrypointLookupTest.php | 45 +++++++++++ tests/Asset/TagRendererTest.php | 47 +++++++++++ tests/IntegrationTest.php | 32 +++++++- tests/fixtures/build/entrypoints.json | 10 +++ .../fixtures/different_build/entrypoints.json | 3 +- 13 files changed, 266 insertions(+), 10 deletions(-) create mode 100644 src/Asset/IntegrityDataProviderInterface.php create mode 100644 src/Exception/IntegrityDataNotFoundException.php diff --git a/README.md b/README.md index fae6e5b1..a5545f9c 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,10 @@ webpack_encore: # The path where Encore is building the assets - i.e. Encore.setOutputPath() output_path: '%kernel.public_dir%/build' + # if you use Encore.enableIntegrityHashes() and want the bundle + # to automatically add "integrity" attributes to your tags + # integrity_hashes: true + # if you have multiple builds: # builds: # pass "frontend" as the 3rg arg to the Twig functions diff --git a/src/Asset/EntrypointLookup.php b/src/Asset/EntrypointLookup.php index 973c3a83..241a9690 100644 --- a/src/Asset/EntrypointLookup.php +++ b/src/Asset/EntrypointLookup.php @@ -10,6 +10,7 @@ namespace Symfony\WebpackEncoreBundle\Asset; use Symfony\WebpackEncoreBundle\Exception\EntrypointNotFoundException; +use Symfony\WebpackEncoreBundle\Exception\IntegrityDataNotFoundException; /** * Returns the CSS or JavaScript files needed for a Webpack entry. @@ -18,7 +19,7 @@ * * @final */ -class EntrypointLookup implements EntrypointLookupInterface +class EntrypointLookup implements EntrypointLookupInterface, IntegrityDataProviderInterface { private $entrypointJsonPath; @@ -41,6 +42,20 @@ public function getCssFiles(string $entryName): array return $this->getEntryFiles($entryName, 'css'); } + public function getIntegrityData(): array + { + $entriesData = $this->getEntriesData(); + + if (!array_key_exists('integrity', $entriesData)) { + throw new IntegrityDataNotFoundException(sprintf( + 'Could not find an "integrity" key in "%s": please check if your version of Webpack Encore supports it', + $this->entrypointJsonPath + )); + } + + return $entriesData['integrity']; + } + /** * Resets the state of this service. */ diff --git a/src/Asset/IntegrityDataProviderInterface.php b/src/Asset/IntegrityDataProviderInterface.php new file mode 100644 index 00000000..998178bc --- /dev/null +++ b/src/Asset/IntegrityDataProviderInterface.php @@ -0,0 +1,20 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\WebpackEncoreBundle\Asset; + +use Symfony\WebpackEncoreBundle\Exception\IntegrityDataNotFoundException; + +interface IntegrityDataProviderInterface +{ + /** + * @throws IntegrityDataNotFoundException if the entrypoints.json file does not contain an "integrity" key + */ + public function getIntegrityData(): array; +} diff --git a/src/Asset/TagRenderer.php b/src/Asset/TagRenderer.php index a7661b00..a62ffcb3 100644 --- a/src/Asset/TagRenderer.php +++ b/src/Asset/TagRenderer.php @@ -18,9 +18,12 @@ final class TagRenderer private $packages; + private $enableIntegrityHashes; + public function __construct( $entrypointLookupCollection, - Packages $packages + Packages $packages, + bool $enableIntegrityHashes = false ) { if ($entrypointLookupCollection instanceof EntrypointLookupInterface) { @trigger_error(sprintf('The "$entrypointLookupCollection" argument in method "%s()" must be an instance of EntrypointLookupCollection.', __METHOD__), E_USER_DEPRECATED); @@ -37,15 +40,45 @@ public function __construct( } $this->packages = $packages; + $this->enableIntegrityHashes = $enableIntegrityHashes; } public function renderWebpackScriptTags(string $entryName, string $packageName = null, string $entrypointName = '_default'): string { $scriptTags = []; - foreach ($this->getEntrypointLookup($entrypointName)->getJavaScriptFiles($entryName) as $filename) { + $entryPointLookup = $this->getEntrypointLookup($entrypointName); + + $integrityAlgorithm = null; + $integrityHashes = []; + + if ($this->enableIntegrityHashes && ($entryPointLookup instanceof IntegrityDataProviderInterface)) { + $integrityData = $entryPointLookup->getIntegrityData(); + $integrityAlgorithm = $integrityData['algorithm'] ?? null; + $integrityHashes = $integrityData['hashes'] ?? []; + } + + foreach ($entryPointLookup->getJavaScriptFiles($entryName) as $filename) { + $attributes = [ + 'src' => $this->getAssetPath($filename, $packageName), + ]; + + if ($integrityAlgorithm && !empty($integrityHashes[$filename])) { + $attributes['integrity'] = sprintf( + '%s-%s', + $integrityAlgorithm, + $integrityHashes[$filename] + ); + } + $scriptTags[] = sprintf( - '', - htmlentities($this->getAssetPath($filename, $packageName)) + '', + implode(' ', array_map( + function ($key, $value) { + return sprintf('%s="%s"', $key, htmlentities($value)); + }, + array_keys($attributes), + $attributes + )) ); } @@ -55,10 +88,40 @@ public function renderWebpackScriptTags(string $entryName, string $packageName = public function renderWebpackLinkTags(string $entryName, string $packageName = null, string $entrypointName = '_default'): string { $scriptTags = []; - foreach ($this->getEntrypointLookup($entrypointName)->getCssFiles($entryName) as $filename) { + $entryPointLookup = $this->getEntrypointLookup($entrypointName); + + $integrityAlgorithm = null; + $integrityHashes = []; + + if ($this->enableIntegrityHashes && ($entryPointLookup instanceof IntegrityDataProviderInterface)) { + $integrityData = $entryPointLookup->getIntegrityData(); + $integrityAlgorithm = $integrityData['algorithm'] ?? null; + $integrityHashes = $integrityData['hashes'] ?? []; + } + + foreach ($entryPointLookup->getCssFiles($entryName) as $filename) { + $attributes = [ + 'rel' => 'stylesheet', + 'href' => $this->getAssetPath($filename, $packageName), + ]; + + if (!empty($integrityHashes[$filename])) { + $attributes['integrity'] = sprintf( + '%s-%s', + $integrityAlgorithm, + $integrityHashes[$filename] + ); + } + $scriptTags[] = sprintf( - '', - htmlentities($this->getAssetPath($filename, $packageName)) + '', + implode(' ', array_map( + function ($key, $value) { + return sprintf('%s="%s"', $key, htmlentities($value)); + }, + array_keys($attributes), + $attributes + )) ); } diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 1e762541..95910fc1 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -28,6 +28,10 @@ public function getConfigTreeBuilder() ->isRequired() ->info('The path where Encore is building the assets - i.e. Encore.setOutputPath()') ->end() + ->booleanNode('integrity_hashes') + ->info('Use integrity hashes generated by calling Encore.enableIntegrityHashes()') + ->defaultFalse() + ->end() ->arrayNode('builds') ->useAttributeAsKey('name') ->scalarPrototype() diff --git a/src/DependencyInjection/WebpackEncoreExtension.php b/src/DependencyInjection/WebpackEncoreExtension.php index 035cd658..1f918baa 100644 --- a/src/DependencyInjection/WebpackEncoreExtension.php +++ b/src/DependencyInjection/WebpackEncoreExtension.php @@ -39,6 +39,8 @@ public function load(array $configs, ContainerBuilder $container) ->replaceArgument(0, $factories['_default']); $container->getDefinition('webpack_encore.entrypoint_lookup_collection') ->replaceArgument(0, ServiceLocatorTagPass::register($container, $factories)); + $container->getDefinition('webpack_encore.tag_renderer') + ->replaceArgument(2, $config['integrity_hashes']); } private function entrypointFactory(ContainerBuilder $container, string $name, string $path): string diff --git a/src/Exception/IntegrityDataNotFoundException.php b/src/Exception/IntegrityDataNotFoundException.php new file mode 100644 index 00000000..a4c5a28f --- /dev/null +++ b/src/Exception/IntegrityDataNotFoundException.php @@ -0,0 +1,14 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\WebpackEncoreBundle\Exception; + +class IntegrityDataNotFoundException extends \InvalidArgumentException +{ +} diff --git a/src/Resources/config/services.xml b/src/Resources/config/services.xml index 2c88c213..683a27c0 100644 --- a/src/Resources/config/services.xml +++ b/src/Resources/config/services.xml @@ -17,6 +17,7 @@ + diff --git a/tests/Asset/EntrypointLookupTest.php b/tests/Asset/EntrypointLookupTest.php index ce39b128..d294fddb 100644 --- a/tests/Asset/EntrypointLookupTest.php +++ b/tests/Asset/EntrypointLookupTest.php @@ -30,6 +30,13 @@ class EntrypointLookupTest extends TestCase ], "css": [] } + }, + "integrity": { + "algorithm": "sha384", + "hashes": { + "file1.js": "Q86c+opr0lBUPWN28BLJFqmLhho+9ZcJpXHorQvX6mYDWJ24RQcdDarXFQYN8HLc", + "styles.css": "ymG7OyjISWrOpH9jsGvajKMDEOP/mKJq8bHC0XdjQA6P8sg2nu+2RLQxcNNwE/3J" + } } } EOF; @@ -91,6 +98,31 @@ public function testEmptyReturnOnValidEntryNoJsOrCssFile() ); } + public function testGetIntegrityData() + { + $this->assertEquals([ + 'algorithm' => 'sha384', + 'hashes' => [ + 'file1.js' => 'Q86c+opr0lBUPWN28BLJFqmLhho+9ZcJpXHorQvX6mYDWJ24RQcdDarXFQYN8HLc', + 'styles.css' => 'ymG7OyjISWrOpH9jsGvajKMDEOP/mKJq8bHC0XdjQA6P8sg2nu+2RLQxcNNwE/3J', + ] + ], $this->entrypointLookup->getIntegrityData()); + } + + public function testNoExceptionOnMissingIntegrityKeyInJsonIfNotUsed() + { + $filename = tempnam(sys_get_temp_dir(), 'WebpackEncoreBundle'); + file_put_contents($filename, '{ "entrypoints": { "other_entry": { "js": { } } } }'); + + $this->entrypointLookup = new EntrypointLookup($filename); + $this->assertEmpty( + $this->entrypointLookup->getJavaScriptFiles('other_entry') + ); + $this->assertEmpty( + $this->entrypointLookup->getCssFiles('other_entry') + ); + } + /** * @expectedException \InvalidArgumentException * @expectedExceptionMessageContains There was a problem JSON decoding the @@ -117,6 +149,19 @@ public function testExceptionOnMissingEntrypointsKeyInJson() $this->entrypointLookup->getJavaScriptFiles('an_entry'); } + /** + * @expectedException Symfony\WebpackEncoreBundle\Exception\IntegrityDataNotFoundException + * @expectedExceptionMessageContains Could not find an "integrity" key in the + */ + public function testExceptionOnMissingIntegrityKeyInJsonOnUse() + { + $filename = tempnam(sys_get_temp_dir(), 'WebpackEncoreBundle'); + file_put_contents($filename, '{ "entrypoints": {} }'); + + $this->entrypointLookup = new EntrypointLookup($filename); + $this->entrypointLookup->getIntegrityData(); + } + /** * @expectedException \InvalidArgumentException * @expectedExceptionMessage Could not find the entrypoints file diff --git a/tests/Asset/TagRendererTest.php b/tests/Asset/TagRendererTest.php index 59feda16..8fc81ee5 100644 --- a/tests/Asset/TagRendererTest.php +++ b/tests/Asset/TagRendererTest.php @@ -6,6 +6,7 @@ use Symfony\Component\Asset\Packages; use Symfony\WebpackEncoreBundle\Asset\EntrypointLookupInterface; use Symfony\WebpackEncoreBundle\Asset\EntrypointLookupCollection; +use Symfony\WebpackEncoreBundle\Asset\IntegrityDataProviderInterface; use Symfony\WebpackEncoreBundle\Asset\TagRenderer; class TagRendererTest extends TestCase @@ -128,4 +129,50 @@ public function testRenderScriptTagsWithinAnEntryPointCollection() ); } + public function testRenderScriptTagsWithHashes() + { + $entrypointLookup = $this->createMock([ + EntrypointLookupInterface::class, + IntegrityDataProviderInterface::class, + ]); + $entrypointLookup->expects($this->once()) + ->method('getJavaScriptFiles') + ->willReturn(['/build/file1.js', '/build/file2.js']); + $entrypointLookup->expects($this->once()) + ->method('getIntegrityData') + ->willReturn([ + 'algorithm' => 'sha384', + 'hashes' => [ + '/build/file1.js' => 'Q86c+opr0lBUPWN28BLJFqmLhho+9ZcJpXHorQvX6mYDWJ24RQcdDarXFQYN8HLc', + '/build/file2.js' => 'ymG7OyjISWrOpH9jsGvajKMDEOP/mKJq8bHC0XdjQA6P8sg2nu+2RLQxcNNwE/3J', + ] + ]); + $entrypointCollection = $this->createMock(EntrypointLookupCollection::class); + $entrypointCollection->expects($this->once()) + ->method('getEntrypointLookup') + ->withConsecutive(['_default']) + ->will($this->onConsecutiveCalls($entrypointLookup)); + + $packages = $this->createMock(Packages::class); + $packages->expects($this->exactly(2)) + ->method('getUrl') + ->withConsecutive( + ['/build/file1.js', 'custom_package'], + ['/build/file2.js', 'custom_package'] + ) + ->willReturnCallback(function ($path) { + return 'http://localhost:8080' . $path; + }); + $renderer = new TagRenderer($entrypointCollection, $packages, true); + + $output = $renderer->renderWebpackScriptTags('my_entry', 'custom_package'); + $this->assertContains( + '', + $output + ); + $this->assertContains( + '', + $output + ); + } } diff --git a/tests/IntegrationTest.php b/tests/IntegrationTest.php index 361cb83b..62b4925b 100644 --- a/tests/IntegrationTest.php +++ b/tests/IntegrationTest.php @@ -77,16 +77,45 @@ public function testEntriesAreNotRepeteadWhenAlreadyOutputIntegration() $html2 ); } + + public function testEntriesWithHashesIntegration() + { + $kernel = new WebpackEncoreIntegrationTestKernel(true, true); + $kernel->boot(); + $container = $kernel->getContainer(); + + $html1 = $container->get('twig')->render('@integration_test/template.twig'); + $this->assertContains( + '', + $html1 + ); + $this->assertContains( + '' . + '', + $html1 + ); + $this->assertContains( + '', + $html1 + ); + $this->assertContains( + '' . + '', + $html1 + ); + } } class WebpackEncoreIntegrationTestKernel extends Kernel { private $enableAssets; + private $enableIntegrityHashes; - public function __construct($enableAssets) + public function __construct($enableAssets, $enableIntegrityHashes = false) { parent::__construct('test', true); $this->enableAssets = $enableAssets; + $this->enableIntegrityHashes = $enableIntegrityHashes; } public function registerBundles() @@ -117,6 +146,7 @@ public function registerContainerConfiguration(LoaderInterface $loader) $container->loadFromExtension('webpack_encore', [ 'output_path' => __DIR__.'/fixtures/build', + 'integrity_hashes' => $this->enableIntegrityHashes, 'builds' => [ 'different_build' => __DIR__.'/fixtures/different_build' ] diff --git a/tests/fixtures/build/entrypoints.json b/tests/fixtures/build/entrypoints.json index 5f1c9902..cdac0b78 100644 --- a/tests/fixtures/build/entrypoints.json +++ b/tests/fixtures/build/entrypoints.json @@ -16,5 +16,15 @@ "build/file3.js" ] } + }, + "integrity": { + "algorithm": "sha384", + "hashes": { + "build/file1.js": "Q86c+opr0lBUPWN28BLJFqmLhho+9ZcJpXHorQvX6mYDWJ24RQcdDarXFQYN8HLc", + "build/file2.js": "ymG7OyjISWrOpH9jsGvajKMDEOP/mKJq8bHC0XdjQA6P8sg2nu+2RLQxcNNwE/3J", + "build/styles.css": "4g+Zv0iELStVvA4/B27g4TQHUMwZttA5TEojjUyB8Gl5p7sarU4y+VTSGMrNab8n", + "build/styles2.css": "hfZmq9+2oI5Cst4/F4YyS2tJAAYdGz7vqSMP8cJoa8bVOr2kxNRLxSw6P8UZjwUn", + "build/file3.js": "ZU3hiTN/+Va9WVImPi+cI0/j/Q7SzAVezqL1aEXha8sVgE5HU6/0wKUxj1LEnkC9" + } } } diff --git a/tests/fixtures/different_build/entrypoints.json b/tests/fixtures/different_build/entrypoints.json index 1128194b..ba8a8e34 100644 --- a/tests/fixtures/different_build/entrypoints.json +++ b/tests/fixtures/different_build/entrypoints.json @@ -18,5 +18,6 @@ "build/styles4.css" ] } - } + }, + "integrity": {} }