Skip to content

Commit e023d90

Browse files
committed
Add support for integrity hashes
1 parent ae7526c commit e023d90

File tree

13 files changed

+266
-10
lines changed

13 files changed

+266
-10
lines changed

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ webpack_encore:
2323
# The path where Encore is building the assets - i.e. Encore.setOutputPath()
2424
output_path: '%kernel.public_dir%/build'
2525

26+
# if you use Encore.enableIntegrityHashes() and want the bundle
27+
# to automatically add "integrity" attributes to your tags
28+
# integrity_hashes: true
29+
2630
# if you have multiple builds:
2731
# builds:
2832
# pass "frontend" as the 3rg arg to the Twig functions

src/Asset/EntrypointLookup.php

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
namespace Symfony\WebpackEncoreBundle\Asset;
1111

1212
use Symfony\WebpackEncoreBundle\Exception\EntrypointNotFoundException;
13+
use Symfony\WebpackEncoreBundle\Exception\IntegrityDataNotFoundException;
1314

1415
/**
1516
* Returns the CSS or JavaScript files needed for a Webpack entry.
@@ -18,7 +19,7 @@
1819
*
1920
* @final
2021
*/
21-
class EntrypointLookup implements EntrypointLookupInterface
22+
class EntrypointLookup implements EntrypointLookupInterface, IntegrityDataProviderInterface
2223
{
2324
private $entrypointJsonPath;
2425

@@ -41,6 +42,20 @@ public function getCssFiles(string $entryName): array
4142
return $this->getEntryFiles($entryName, 'css');
4243
}
4344

45+
public function getIntegrityData(): array
46+
{
47+
$entriesData = $this->getEntriesData();
48+
49+
if (!array_key_exists('integrity', $entriesData)) {
50+
throw new IntegrityDataNotFoundException(sprintf(
51+
'Could not find an "integrity" key in "%s": please check if your version of Webpack Encore supports it',
52+
$this->entrypointJsonPath
53+
));
54+
}
55+
56+
return $entriesData['integrity'];
57+
}
58+
4459
/**
4560
* Resets the state of this service.
4661
*/
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony WebpackEncoreBundle package.
5+
* (c) Fabien Potencier <[email protected]>
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
namespace Symfony\WebpackEncoreBundle\Asset;
11+
12+
use Symfony\WebpackEncoreBundle\Exception\IntegrityDataNotFoundException;
13+
14+
interface IntegrityDataProviderInterface
15+
{
16+
/**
17+
* @throws IntegrityDataNotFoundException if the entrypoints.json file does not contain an "integrity" key
18+
*/
19+
public function getIntegrityData(): array;
20+
}

src/Asset/TagRenderer.php

Lines changed: 70 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,12 @@ final class TagRenderer
1818

1919
private $packages;
2020

21+
private $enableIntegrityHashes;
22+
2123
public function __construct(
2224
$entrypointLookupCollection,
23-
Packages $packages
25+
Packages $packages,
26+
bool $enableIntegrityHashes = false
2427
) {
2528
if ($entrypointLookupCollection instanceof EntrypointLookupInterface) {
2629
@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(
3740
}
3841

3942
$this->packages = $packages;
43+
$this->enableIntegrityHashes = $enableIntegrityHashes;
4044
}
4145

4246
public function renderWebpackScriptTags(string $entryName, string $packageName = null, string $entrypointName = '_default'): string
4347
{
4448
$scriptTags = [];
45-
foreach ($this->getEntrypointLookup($entrypointName)->getJavaScriptFiles($entryName) as $filename) {
49+
$entryPointLookup = $this->getEntrypointLookup($entrypointName);
50+
51+
$integrityAlgorithm = null;
52+
$integrityHashes = [];
53+
54+
if ($this->enableIntegrityHashes && ($entryPointLookup instanceof IntegrityDataProviderInterface)) {
55+
$integrityData = $entryPointLookup->getIntegrityData();
56+
$integrityAlgorithm = $integrityData['algorithm'] ?? null;
57+
$integrityHashes = $integrityData['hashes'] ?? [];
58+
}
59+
60+
foreach ($entryPointLookup->getJavaScriptFiles($entryName) as $filename) {
61+
$attributes = [
62+
'src' => $this->getAssetPath($filename, $packageName),
63+
];
64+
65+
if ($integrityAlgorithm && !empty($integrityHashes[$filename])) {
66+
$attributes['integrity'] = sprintf(
67+
'%s-%s',
68+
$integrityAlgorithm,
69+
$integrityHashes[$filename]
70+
);
71+
}
72+
4673
$scriptTags[] = sprintf(
47-
'<script src="%s"></script>',
48-
htmlentities($this->getAssetPath($filename, $packageName))
74+
'<script %s></script>',
75+
implode(' ', array_map(
76+
function ($key, $value) {
77+
return sprintf('%s="%s"', $key, htmlentities($value));
78+
},
79+
array_keys($attributes),
80+
$attributes
81+
))
4982
);
5083
}
5184

@@ -55,10 +88,40 @@ public function renderWebpackScriptTags(string $entryName, string $packageName =
5588
public function renderWebpackLinkTags(string $entryName, string $packageName = null, string $entrypointName = '_default'): string
5689
{
5790
$scriptTags = [];
58-
foreach ($this->getEntrypointLookup($entrypointName)->getCssFiles($entryName) as $filename) {
91+
$entryPointLookup = $this->getEntrypointLookup($entrypointName);
92+
93+
$integrityAlgorithm = null;
94+
$integrityHashes = [];
95+
96+
if ($this->enableIntegrityHashes && ($entryPointLookup instanceof IntegrityDataProviderInterface)) {
97+
$integrityData = $entryPointLookup->getIntegrityData();
98+
$integrityAlgorithm = $integrityData['algorithm'] ?? null;
99+
$integrityHashes = $integrityData['hashes'] ?? [];
100+
}
101+
102+
foreach ($entryPointLookup->getCssFiles($entryName) as $filename) {
103+
$attributes = [
104+
'rel' => 'stylesheet',
105+
'href' => $this->getAssetPath($filename, $packageName),
106+
];
107+
108+
if (!empty($integrityHashes[$filename])) {
109+
$attributes['integrity'] = sprintf(
110+
'%s-%s',
111+
$integrityAlgorithm,
112+
$integrityHashes[$filename]
113+
);
114+
}
115+
59116
$scriptTags[] = sprintf(
60-
'<link rel="stylesheet" href="%s">',
61-
htmlentities($this->getAssetPath($filename, $packageName))
117+
'<link %s>',
118+
implode(' ', array_map(
119+
function ($key, $value) {
120+
return sprintf('%s="%s"', $key, htmlentities($value));
121+
},
122+
array_keys($attributes),
123+
$attributes
124+
))
62125
);
63126
}
64127

src/DependencyInjection/Configuration.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ public function getConfigTreeBuilder()
2828
->isRequired()
2929
->info('The path where Encore is building the assets - i.e. Encore.setOutputPath()')
3030
->end()
31+
->booleanNode('integrity_hashes')
32+
->info('Use integrity hashes generated by calling Encore.enableIntegrityHashes()')
33+
->defaultFalse()
34+
->end()
3135
->arrayNode('builds')
3236
->useAttributeAsKey('name')
3337
->scalarPrototype()

src/DependencyInjection/WebpackEncoreExtension.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ public function load(array $configs, ContainerBuilder $container)
3939
->replaceArgument(0, $factories['_default']);
4040
$container->getDefinition('webpack_encore.entrypoint_lookup_collection')
4141
->replaceArgument(0, ServiceLocatorTagPass::register($container, $factories));
42+
$container->getDefinition('webpack_encore.tag_renderer')
43+
->replaceArgument(2, $config['integrity_hashes']);
4244
}
4345

4446
private function entrypointFactory(ContainerBuilder $container, string $name, string $path): string
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony WebpackEncoreBundle package.
5+
* (c) Fabien Potencier <[email protected]>
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
namespace Symfony\WebpackEncoreBundle\Exception;
11+
12+
class IntegrityDataNotFoundException extends \InvalidArgumentException
13+
{
14+
}

src/Resources/config/services.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
<service id="webpack_encore.tag_renderer" class="Symfony\WebpackEncoreBundle\Asset\TagRenderer">
1818
<argument type="service" id="webpack_encore.entrypoint_lookup_collection" />
1919
<argument type="service" id="assets.packages" />
20+
<argument /><!-- enable integrity hashes -->
2021
</service>
2122

2223
<service id="webpack_encore.twig_entry_files_extension" class="Symfony\WebpackEncoreBundle\Twig\EntryFilesTwigExtension">

tests/Asset/EntrypointLookupTest.php

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@ class EntrypointLookupTest extends TestCase
3030
],
3131
"css": []
3232
}
33+
},
34+
"integrity": {
35+
"algorithm": "sha384",
36+
"hashes": {
37+
"file1.js": "Q86c+opr0lBUPWN28BLJFqmLhho+9ZcJpXHorQvX6mYDWJ24RQcdDarXFQYN8HLc",
38+
"styles.css": "ymG7OyjISWrOpH9jsGvajKMDEOP/mKJq8bHC0XdjQA6P8sg2nu+2RLQxcNNwE/3J"
39+
}
3340
}
3441
}
3542
EOF;
@@ -91,6 +98,31 @@ public function testEmptyReturnOnValidEntryNoJsOrCssFile()
9198
);
9299
}
93100

101+
public function testGetIntegrityData()
102+
{
103+
$this->assertEquals([
104+
'algorithm' => 'sha384',
105+
'hashes' => [
106+
'file1.js' => 'Q86c+opr0lBUPWN28BLJFqmLhho+9ZcJpXHorQvX6mYDWJ24RQcdDarXFQYN8HLc',
107+
'styles.css' => 'ymG7OyjISWrOpH9jsGvajKMDEOP/mKJq8bHC0XdjQA6P8sg2nu+2RLQxcNNwE/3J',
108+
]
109+
], $this->entrypointLookup->getIntegrityData());
110+
}
111+
112+
public function testNoExceptionOnMissingIntegrityKeyInJsonIfNotUsed()
113+
{
114+
$filename = tempnam(sys_get_temp_dir(), 'WebpackEncoreBundle');
115+
file_put_contents($filename, '{ "entrypoints": { "other_entry": { "js": { } } } }');
116+
117+
$this->entrypointLookup = new EntrypointLookup($filename);
118+
$this->assertEmpty(
119+
$this->entrypointLookup->getJavaScriptFiles('other_entry')
120+
);
121+
$this->assertEmpty(
122+
$this->entrypointLookup->getCssFiles('other_entry')
123+
);
124+
}
125+
94126
/**
95127
* @expectedException \InvalidArgumentException
96128
* @expectedExceptionMessageContains There was a problem JSON decoding the
@@ -117,6 +149,19 @@ public function testExceptionOnMissingEntrypointsKeyInJson()
117149
$this->entrypointLookup->getJavaScriptFiles('an_entry');
118150
}
119151

152+
/**
153+
* @expectedException Symfony\WebpackEncoreBundle\Exception\IntegrityDataNotFoundException
154+
* @expectedExceptionMessageContains Could not find an "integrity" key in the
155+
*/
156+
public function testExceptionOnMissingIntegrityKeyInJsonOnUse()
157+
{
158+
$filename = tempnam(sys_get_temp_dir(), 'WebpackEncoreBundle');
159+
file_put_contents($filename, '{ "entrypoints": {} }');
160+
161+
$this->entrypointLookup = new EntrypointLookup($filename);
162+
$this->entrypointLookup->getIntegrityData();
163+
}
164+
120165
/**
121166
* @expectedException \InvalidArgumentException
122167
* @expectedExceptionMessage Could not find the entrypoints file

tests/Asset/TagRendererTest.php

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use Symfony\Component\Asset\Packages;
77
use Symfony\WebpackEncoreBundle\Asset\EntrypointLookupInterface;
88
use Symfony\WebpackEncoreBundle\Asset\EntrypointLookupCollection;
9+
use Symfony\WebpackEncoreBundle\Asset\IntegrityDataProviderInterface;
910
use Symfony\WebpackEncoreBundle\Asset\TagRenderer;
1011

1112
class TagRendererTest extends TestCase
@@ -128,4 +129,50 @@ public function testRenderScriptTagsWithinAnEntryPointCollection()
128129
);
129130
}
130131

132+
public function testRenderScriptTagsWithHashes()
133+
{
134+
$entrypointLookup = $this->createMock([
135+
EntrypointLookupInterface::class,
136+
IntegrityDataProviderInterface::class,
137+
]);
138+
$entrypointLookup->expects($this->once())
139+
->method('getJavaScriptFiles')
140+
->willReturn(['/build/file1.js', '/build/file2.js']);
141+
$entrypointLookup->expects($this->once())
142+
->method('getIntegrityData')
143+
->willReturn([
144+
'algorithm' => 'sha384',
145+
'hashes' => [
146+
'/build/file1.js' => 'Q86c+opr0lBUPWN28BLJFqmLhho+9ZcJpXHorQvX6mYDWJ24RQcdDarXFQYN8HLc',
147+
'/build/file2.js' => 'ymG7OyjISWrOpH9jsGvajKMDEOP/mKJq8bHC0XdjQA6P8sg2nu+2RLQxcNNwE/3J',
148+
]
149+
]);
150+
$entrypointCollection = $this->createMock(EntrypointLookupCollection::class);
151+
$entrypointCollection->expects($this->once())
152+
->method('getEntrypointLookup')
153+
->withConsecutive(['_default'])
154+
->will($this->onConsecutiveCalls($entrypointLookup));
155+
156+
$packages = $this->createMock(Packages::class);
157+
$packages->expects($this->exactly(2))
158+
->method('getUrl')
159+
->withConsecutive(
160+
['/build/file1.js', 'custom_package'],
161+
['/build/file2.js', 'custom_package']
162+
)
163+
->willReturnCallback(function ($path) {
164+
return 'http://localhost:8080' . $path;
165+
});
166+
$renderer = new TagRenderer($entrypointCollection, $packages, true);
167+
168+
$output = $renderer->renderWebpackScriptTags('my_entry', 'custom_package');
169+
$this->assertContains(
170+
'<script src="http://localhost:8080/build/file1.js" integrity="sha384-Q86c+opr0lBUPWN28BLJFqmLhho+9ZcJpXHorQvX6mYDWJ24RQcdDarXFQYN8HLc"></script>',
171+
$output
172+
);
173+
$this->assertContains(
174+
'<script src="http://localhost:8080/build/file2.js" integrity="sha384-ymG7OyjISWrOpH9jsGvajKMDEOP/mKJq8bHC0XdjQA6P8sg2nu+2RLQxcNNwE/3J"></script>',
175+
$output
176+
);
177+
}
131178
}

tests/IntegrationTest.php

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,16 +77,45 @@ public function testEntriesAreNotRepeteadWhenAlreadyOutputIntegration()
7777
$html2
7878
);
7979
}
80+
81+
public function testEntriesWithHashesIntegration()
82+
{
83+
$kernel = new WebpackEncoreIntegrationTestKernel(true, true);
84+
$kernel->boot();
85+
$container = $kernel->getContainer();
86+
87+
$html1 = $container->get('twig')->render('@integration_test/template.twig');
88+
$this->assertContains(
89+
'<script src="/build/file1.js" integrity="sha384-Q86c+opr0lBUPWN28BLJFqmLhho+9ZcJpXHorQvX6mYDWJ24RQcdDarXFQYN8HLc"></script>',
90+
$html1
91+
);
92+
$this->assertContains(
93+
'<link rel="stylesheet" href="/build/styles.css" integrity="sha384-4g+Zv0iELStVvA4/B27g4TQHUMwZttA5TEojjUyB8Gl5p7sarU4y+VTSGMrNab8n">' .
94+
'<link rel="stylesheet" href="/build/styles2.css" integrity="sha384-hfZmq9+2oI5Cst4/F4YyS2tJAAYdGz7vqSMP8cJoa8bVOr2kxNRLxSw6P8UZjwUn">',
95+
$html1
96+
);
97+
$this->assertContains(
98+
'<script src="/build/other3.js"></script>',
99+
$html1
100+
);
101+
$this->assertContains(
102+
'<link rel="stylesheet" href="/build/styles3.css">' .
103+
'<link rel="stylesheet" href="/build/styles4.css">',
104+
$html1
105+
);
106+
}
80107
}
81108

82109
class WebpackEncoreIntegrationTestKernel extends Kernel
83110
{
84111
private $enableAssets;
112+
private $enableIntegrityHashes;
85113

86-
public function __construct($enableAssets)
114+
public function __construct($enableAssets, $enableIntegrityHashes = false)
87115
{
88116
parent::__construct('test', true);
89117
$this->enableAssets = $enableAssets;
118+
$this->enableIntegrityHashes = $enableIntegrityHashes;
90119
}
91120

92121
public function registerBundles()
@@ -117,6 +146,7 @@ public function registerContainerConfiguration(LoaderInterface $loader)
117146

118147
$container->loadFromExtension('webpack_encore', [
119148
'output_path' => __DIR__.'/fixtures/build',
149+
'integrity_hashes' => $this->enableIntegrityHashes,
120150
'builds' => [
121151
'different_build' => __DIR__.'/fixtures/different_build'
122152
]

0 commit comments

Comments
 (0)