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": {}
}