Skip to content

Commit

Permalink
Add support for integrity hashes
Browse files Browse the repository at this point in the history
  • Loading branch information
Lyrkan committed Feb 14, 2019
1 parent ae7526c commit e023d90
Show file tree
Hide file tree
Showing 13 changed files with 266 additions and 10 deletions.
4 changes: 4 additions & 0 deletions README.md
Expand Up @@ -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
Expand Down
17 changes: 16 additions & 1 deletion src/Asset/EntrypointLookup.php
Expand Up @@ -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.
Expand All @@ -18,7 +19,7 @@
*
* @final
*/
class EntrypointLookup implements EntrypointLookupInterface
class EntrypointLookup implements EntrypointLookupInterface, IntegrityDataProviderInterface
{
private $entrypointJsonPath;

Expand All @@ -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.
*/
Expand Down
20 changes: 20 additions & 0 deletions src/Asset/IntegrityDataProviderInterface.php
@@ -0,0 +1,20 @@
<?php

/*
* This file is part of the Symfony WebpackEncoreBundle package.
* (c) Fabien Potencier <fabien@symfony.com>
* 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;
}
77 changes: 70 additions & 7 deletions src/Asset/TagRenderer.php
Expand Up @@ -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);
Expand All @@ -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(
'<script src="%s"></script>',
htmlentities($this->getAssetPath($filename, $packageName))
'<script %s></script>',
implode(' ', array_map(
function ($key, $value) {
return sprintf('%s="%s"', $key, htmlentities($value));
},
array_keys($attributes),
$attributes
))
);
}

Expand All @@ -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(
'<link rel="stylesheet" href="%s">',
htmlentities($this->getAssetPath($filename, $packageName))
'<link %s>',
implode(' ', array_map(
function ($key, $value) {
return sprintf('%s="%s"', $key, htmlentities($value));
},
array_keys($attributes),
$attributes
))
);
}

Expand Down
4 changes: 4 additions & 0 deletions src/DependencyInjection/Configuration.php
Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions src/DependencyInjection/WebpackEncoreExtension.php
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions src/Exception/IntegrityDataNotFoundException.php
@@ -0,0 +1,14 @@
<?php

/*
* This file is part of the Symfony WebpackEncoreBundle package.
* (c) Fabien Potencier <fabien@symfony.com>
* 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
{
}
1 change: 1 addition & 0 deletions src/Resources/config/services.xml
Expand Up @@ -17,6 +17,7 @@
<service id="webpack_encore.tag_renderer" class="Symfony\WebpackEncoreBundle\Asset\TagRenderer">
<argument type="service" id="webpack_encore.entrypoint_lookup_collection" />
<argument type="service" id="assets.packages" />
<argument /><!-- enable integrity hashes -->
</service>

<service id="webpack_encore.twig_entry_files_extension" class="Symfony\WebpackEncoreBundle\Twig\EntryFilesTwigExtension">
Expand Down
45 changes: 45 additions & 0 deletions tests/Asset/EntrypointLookupTest.php
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
47 changes: 47 additions & 0 deletions tests/Asset/TagRendererTest.php
Expand Up @@ -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
Expand Down Expand Up @@ -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(
'<script src="http://localhost:8080/build/file1.js" integrity="sha384-Q86c+opr0lBUPWN28BLJFqmLhho+9ZcJpXHorQvX6mYDWJ24RQcdDarXFQYN8HLc"></script>',
$output
);
$this->assertContains(
'<script src="http://localhost:8080/build/file2.js" integrity="sha384-ymG7OyjISWrOpH9jsGvajKMDEOP/mKJq8bHC0XdjQA6P8sg2nu+2RLQxcNNwE/3J"></script>',
$output
);
}
}
32 changes: 31 additions & 1 deletion tests/IntegrationTest.php
Expand Up @@ -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(
'<script src="/build/file1.js" integrity="sha384-Q86c+opr0lBUPWN28BLJFqmLhho+9ZcJpXHorQvX6mYDWJ24RQcdDarXFQYN8HLc"></script>',
$html1
);
$this->assertContains(
'<link rel="stylesheet" href="/build/styles.css" integrity="sha384-4g+Zv0iELStVvA4/B27g4TQHUMwZttA5TEojjUyB8Gl5p7sarU4y+VTSGMrNab8n">' .
'<link rel="stylesheet" href="/build/styles2.css" integrity="sha384-hfZmq9+2oI5Cst4/F4YyS2tJAAYdGz7vqSMP8cJoa8bVOr2kxNRLxSw6P8UZjwUn">',
$html1
);
$this->assertContains(
'<script src="/build/other3.js"></script>',
$html1
);
$this->assertContains(
'<link rel="stylesheet" href="/build/styles3.css">' .
'<link rel="stylesheet" href="/build/styles4.css">',
$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()
Expand Down Expand Up @@ -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'
]
Expand Down

0 comments on commit e023d90

Please sign in to comment.