Skip to content

Commit

Permalink
Merge pull request #16 from odan/csp-nonce
Browse files Browse the repository at this point in the history
Add support for CSP nonce
  • Loading branch information
odan authored Dec 14, 2019
2 parents c756e17 + c9c0f4e commit 352ddd8
Show file tree
Hide file tree
Showing 4 changed files with 136 additions and 56 deletions.
3 changes: 2 additions & 1 deletion .cs.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@
// custom rules
'align_multiline_comment' => ['comment_type' => 'phpdocs_only'], // psr-5
'phpdoc_to_comment' => false,
'no_superfluous_phpdoc_tags' => false,
'array_indentation' => true,
'array_syntax' => ['syntax' => 'short'],
'cast_spaces' => ['space' => 'none'],
'concat_space' => ['spacing' => 'one'],
'compact_nullable_typehint' => true,
'declare_equal_normalize' => ['space' => 'single'],
'increment_style' => ['style' => 'post'],
'list_syntax' => ['syntax' => 'long'],
'list_syntax' => ['syntax' => 'short'],
'no_short_echo_tag' => true,
'phpdoc_add_missing_param_annotation' => ['only_untyped' => false],
'phpdoc_align' => false,
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@ files | array | [] | yes | All assets to be delivered to the browser. [Namespace
inline | bool | false | no | Defines whether the browser downloads the assets inline or via URL.
minify | bool | true | no | Specifies whether JS/CSS compression is enabled or disabled.
name | string | file | no | Defines the output file name within the URL.
nonce | string | | no | The CSP (content security policy) nonce (per request)

### Template

Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"require": {
"php": "^7.0",
"twig/extensions": "^1.5",
"symfony/cache": "^3.2|^4.0|^5.0",
"symfony/cache": "^3.4|^4.2.12|^5.0",
"tubalmartin/cssmin": "^4.1",
"mrclay/jsmin-php": "^2.4"
},
Expand Down
186 changes: 132 additions & 54 deletions src/TwigAssetsEngine.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use tubalmartin\CssMin\Minifier as CssMinifier;
use Twig\Environment;
use Twig\Error\LoaderError;
use Twig\Loader\LoaderInterface;

/**
Expand All @@ -31,6 +30,11 @@ class TwigAssetsEngine
*/
private $cache;

/**
* @var CssMinifier
*/
private $cssMinifier;

/**
* Cache.
*
Expand All @@ -43,18 +47,7 @@ class TwigAssetsEngine
*
* @var array
*/
private $options = [
'cache_adapter' => null,
'cache_name' => 'assets-cache',
'cache_lifetime' => 0,
'cache_path' => null,
'path' => null,
'path_chmod' => 0750,
'minify' => true,
'inline' => false,
'name' => 'file',
'url_base_path' => null,
];
private $options = [];

/**
* Create new instance.
Expand All @@ -77,7 +70,18 @@ public function __construct(Environment $env, array $options)
{
$this->loader = $env->getLoader();

$options = array_replace_recursive($this->options, $options);
$options = array_replace_recursive([
'cache_adapter' => null,
'cache_name' => 'assets-cache',
'cache_lifetime' => 0,
'cache_path' => null,
'path' => null,
'path_chmod' => 0750,
'minify' => true,
'inline' => false,
'name' => 'file',
'url_base_path' => null,
], $options);

if (empty($options['path'])) {
throw new InvalidArgumentException('The option [path] is not defined');
Expand All @@ -103,6 +107,8 @@ public function __construct(Environment $env, array $options)
unset($options['cache_adapter']);

$this->options = $options;

$this->cssMinifier = new CssMinifier();
}

/**
Expand All @@ -111,19 +117,13 @@ public function __construct(Environment $env, array $options)
* @param array $assets Assets
* @param array $options Options
*
* @return string content
* @return string The content
*/
public function assets(array $assets, array $options = []): string
{
$assets = $this->prepareAssets($assets);
$options = (array)array_replace_recursive($this->options, $options);

$cacheKey = $this->getCacheKey($assets, $options);
$cacheItem = $this->cache->getItem($cacheKey);
if ($cacheItem->isHit()) {
return $cacheItem->get();
}

$jsFiles = [];
$cssFiles = [];
foreach ($assets as $file) {
Expand All @@ -137,12 +137,8 @@ public function assets(array $assets, array $options = []): string
}
$cssContent = $this->css($cssFiles, $options);
$jsContent = $this->js($jsFiles, $options);
$result = $cssContent . $jsContent;

$cacheItem->set($result);
$this->cache->save($cacheItem);

return $result;
return $cssContent . $jsContent;
}

/**
Expand All @@ -167,9 +163,7 @@ private function prepareAssets(array $assets): array
*
* @param string $file File
*
* @throws LoaderError
*
* @return string
* @return string The real filename
*/
private function getRealFilename(string $file): string
{
Expand All @@ -186,15 +180,19 @@ private function getRealFilename(string $file): string
* @param array $assets Assets
* @param array $settings Settings
*
* @return string key
* @return string The cache key
*/
private function getCacheKey(array $assets, array $settings): string
{
$keys = [];
foreach ($assets as $file) {
$keys[] = sha1_file($file);
}
$keys[] = sha1(serialize($settings));

// Exclude nonce from cache
unset($settings['nonce']);

$keys[] = sha1((string)json_encode($settings));

return sha1(implode('', $keys));
}
Expand All @@ -205,43 +203,82 @@ private function getCacheKey(array $assets, array $settings): string
* @param array $assets Array of asset that would be embed to css
* @param array $options Array of option / setting
*
* @return string content
* @return string The CSS content
*/
public function css(array $assets, array $options): string
{
$contents = [];
$public = '';
$content = '';

foreach ($assets as $asset) {
if ($this->isExternalUrl($asset)) {
// External url
$contents[] = sprintf('<link rel="stylesheet" type="text/css" href="%s" media="all" />', $asset);
$attributes = $this->createAttributes([
'rel' => 'stylesheet',
'type' => 'text/css',
'href' => $asset,
'media' => 'all',
], $options);

$contents[] = $this->element('link', $attributes, '', false);
continue;
}
$content = $this->getCssContent($asset, $options['minify']);

$fileContent = $this->getCssContent($asset, $options['minify']);

if (!empty($options['inline'])) {
$contents[] = sprintf('<style>%s</style>', $content);
$attributes = $this->createAttributes([], $options);
$contents[] = $this->element('style', $attributes, $fileContent, true);
} else {
$public .= $content . '';
$content .= $fileContent . '';
}
}
if ($public !== '') {

if ($content !== '') {
$name = $options['name'] ?? 'file.css';

if (empty(pathinfo($name, PATHINFO_EXTENSION))) {
$name .= '.css';
}

$urlBasePath = $options['url_base_path'] ?? '';
$url = $this->publicCache->createCacheBustedUrl($name, $public, $urlBasePath);
$url = $this->publicCache->createCacheBustedUrl($name, $content, $urlBasePath);

$contents[] = sprintf('<link rel="stylesheet" type="text/css" href="%s" media="all" />', $url);
$attributes = $this->createAttributes([
'rel' => 'stylesheet',
'type' => 'text/css',
'href' => $url,
'media' => 'all',
], $options);

$contents[] = $this->element('link', $attributes, '', false);
}

return implode("\n", $contents);
}

/**
* Render html element.
*
* @param string $name The element name
* @param array $attributes The attributes
* @param string $content The content
* @param bool $closingTags Has closing tags
*
* @return string The html content
*/
private function element(string $name, array $attributes, string $content, bool $closingTags): string
{
$attr = '';
foreach ($attributes as $key => $value) {
$attr .= sprintf(' %s="%s"', $key, htmlspecialchars($value));
}

$closingTag = $closingTags ? sprintf('>%s</%s>', $content, $name) : ' />';

return sprintf('<%s%s%s', $name, $attr, $closingTag);
}

/**
* Check if url is valid.
*
Expand All @@ -266,17 +303,26 @@ private function isExternalUrl($url): bool
*/
public function getCssContent(string $fileName, bool $minify): string
{
$cacheKey = $this->getCacheKey([$fileName], ['minify' => $minify]);
$cacheItem = $this->cache->getItem($cacheKey);

if ($cacheItem->isHit()) {
return $cacheItem->get();
}

$content = file_get_contents($fileName);

if ($content === false) {
throw new RuntimeException(sprintf('File could could not be read %s', $fileName));
}

if ($minify) {
$compressor = new CssMinifier();
$content = $compressor->run($content);
$content = $this->cssMinifier->run($content);
}

$cacheItem->set($content);
$this->cache->save($cacheItem);

return $content;
}

Expand All @@ -286,55 +332,84 @@ public function getCssContent(string $fileName, bool $minify): string
* @param array $assets Assets
* @param array $options Options
*
* @return string content
* @return string The content
*/
public function js(array $assets, array $options): string
{
$contents = [];
$public = '';
$content = '';

foreach ($assets as $asset) {
if ($this->isExternalUrl($asset)) {
// External url
$contents[] = sprintf('<script src="%s"></script>', $asset);
$attributes = $this->createAttributes(['src' => $asset], $options);
$contents[] = $this->element('script', $attributes, '', true);

continue;
}
$content = $this->getJsContent($asset, $options['minify']);

$fileContent = $this->getJsContent($asset, (bool)$options['minify']);

if (!empty($options['inline'])) {
$contents[] = sprintf('<script>%s</script>', $content);
$attributes = $this->createAttributes([], $options);
$contents[] = $this->element('script', $attributes, $fileContent, true);
} else {
$public .= sprintf("/* %s */\n", basename($asset)) . $content . "\n";
$content .= sprintf("/* %s */\n", basename($asset)) . $fileContent . "\n";
}
}
if ($public !== '') {

if ($content !== '') {
$name = $options['name'] ?? 'file.js';

if (empty(pathinfo($name, PATHINFO_EXTENSION))) {
$name .= '.js';
}

$urlBasePath = $options['url_base_path'] ?? '';
$url = $this->publicCache->createCacheBustedUrl($name, $public, $urlBasePath);

$contents[] = sprintf('<script src="%s"></script>', $url);
$url = $this->publicCache->createCacheBustedUrl($name, $content, $urlBasePath);
$attributes = $this->createAttributes(['src' => $url], $options);
$contents[] = $this->element('script', $attributes, '', true);
}

return implode("\n", $contents);
}

/**
* Minimise JS.
* Create array of html attributes.
*
* @param array $attributes The default values
* @param array $options The options
*
* @return array The html attributes
*/
private function createAttributes(array $attributes, array $options): array
{
if (!empty($options['nonce'])) {
$attributes['nonce'] = $options['nonce'];
}

return $attributes;
}

/**
* Minimize JS.
*
* @param string $file Name of default JS file
* @param bool $minify Minify js if true
*
* @throws RuntimeException
*
* @return string JavaScript code
* @return string The JavaScript code
*/
private function getJsContent(string $file, bool $minify): string
{
$cacheKey = $this->getCacheKey([$file], ['minify' => $minify]);
$cacheItem = $this->cache->getItem($cacheKey);

if ($cacheItem->isHit()) {
return $cacheItem->get();
}

$content = file_get_contents($file);

if ($content === false) {
Expand All @@ -345,6 +420,9 @@ private function getJsContent(string $file, bool $minify): string
$content = JSMin::minify($content);
}

$cacheItem->set($content);
$this->cache->save($cacheItem);

return $content;
}
}

0 comments on commit 352ddd8

Please sign in to comment.