src/View/CSPBackend.php
<?php
namespace Firesphere\CSPHeaders\View;
use Firesphere\CSPHeaders\Builders\CSSBuilder;
use Firesphere\CSPHeaders\Builders\JSBuilder;
use Firesphere\CSPHeaders\Extensions\ControllerCSPExtension;
use Firesphere\CSPHeaders\Traits\CSPBackendTrait;
use SilverStripe\Core\Config\Configurable;
use SilverStripe\Core\Manifest\ModuleResourceLoader;
use SilverStripe\Dev\Deprecation;
use SilverStripe\ORM\ValidationException;
use SilverStripe\View\Requirements_Backend;
class CSPBackend extends Requirements_Backend
{
use Configurable;
use CSPBackendTrait;
public const SHA256 = 'sha256';
public const SHA384 = 'sha384';
public function __construct()
{
$this->setCssBuilder(new CSSBuilder($this));
$this->setJsBuilder(new JSBuilder($this));
}
/**
* Specific method for JS insertion
*
* @param $js
* @param null|string $uniquenessID
*/
public function customScript($js, $uniquenessID = null): void
{
ControllerCSPExtension::addJS($js);
parent::customScript($js, $uniquenessID);
}
/**
* @param $css
* @param null|string $uniquenessID
*/
public function customCSS($css, $uniquenessID = null): void
{
ControllerCSPExtension::addCSS($css);
parent::customCSS($css, $uniquenessID);
}
/**
* Add the following custom HTML code to the `<head>` section of the page
* @param string $html Custom HTML code
* @param string|null $uniquenessID A unique ID that ensures a piece of code is only added once
*/
public function insertHeadTags($html, $uniquenessID = null): void
{
$uniquenessID = $uniquenessID ?: hash(static::SHA256, $html);
$type = $this->getTagType($html);
if ($type === 'javascript') {
$options = $this->getOptions($html);
// Grab everything between the script tags. All matches are okay, but the last one is the actual script content
preg_match('/<script (.*?)>(.*?)<\/script>/s', $html, $match);
$scriptContent = end($match);
static::$headJS[$uniquenessID] = [$scriptContent => $options];
ControllerCSPExtension::addJS($scriptContent);
} elseif ($type === 'css') {
$options = $this->getOptions($html); // SimpleXML does it's job here, we see the outcome
static::$headCSS[$uniquenessID] = [strip_tags($html) => $options];
ControllerCSPExtension::addCSS(strip_tags($html));
} else {
$this->customHeadTags[$uniquenessID] = $html;
}
}
/**
* Determine the type of the head tag if it's js or css
* @param string $html
* @return string|null
*/
public function getTagType($html): ?string
{
$html = trim($html);
if (strpos($html, '<script') === 0) {
return 'javascript';
}
if (strpos($html, '<style') === 0) {
return 'css';
}
return null;
}
/**
* @param $html
* @return array
*/
protected function getOptions($html): array
{
$doc = simplexml_load_string(str_replace('\\', '', $html)); // SimpleXML does it's job here, we see the outcome
$option = [];
foreach ($doc->attributes() as $key => $attribute) {
$option[$key] = (string)$attribute; // Add each option as a string
}
return $option;
}
/**
* Register the given JavaScript file as required.
*
* @param string $file Either relative to docroot or in the form "vendor/package:resource"
* @param array $options List of options. Available options include:
* - 'provides' : List of scripts files included in this file
* - 'async' : Boolean value to set async attribute to script tag
* - 'defer' : Boolean value to set defer attribute to script tag
* - 'type' : Override script type= value.
*/
public function javascript($file, $options = []): void
{
$file = ModuleResourceLoader::singleton()->resolvePath($file);
// Get type
$type = $options['type'] ?? $this->javascript[$file]['type'] ?? 'text/javascript';
$fallback = $options['fallback'] ?? $this->javascript[$file]['fallback'] ?? false;
$this->javascript[$file] = [
'async' => $this->isAsync($file, $options),
'defer' => $this->isDefer($file, $options),
'type' => $type,
'fallback' => $fallback
];
// Record scripts included in this file
if (isset($options['provides'])) {
$this->providedJavascript[$file] = array_values($options['provides']);
}
}
/**
* @param $file
* @param $options
* @return bool
*/
protected function isAsync($file, $options): bool
{
// make sure that async/defer is set if it is set once even if file is included multiple times
return (
(isset($options['async']) && isset($options['async']) === true)
|| (isset($this->javascript[$file]['async']) && $this->javascript[$file]['async'] === true)
);
}
/**
* @param $file
* @param $options
* @return bool
*/
protected function isDefer($file, $options): bool
{
return (
(isset($options['defer']) && isset($options['defer']) === true)
|| (isset($this->javascript[$file]['defer']) && $this->javascript[$file]['defer'] === true)
);
}
/**
* Copy-paste of the original backend code. There is no way to override this in a more clean way
*
* Update the given HTML content with the appropriate include tags for the registered
* requirements. Needs to receive a valid HTML/XHTML template in the $content parameter,
* including a head and body tag.
*
* We need to override the whole method to adjust for SRI in javascript
*
* @param string $content HTML content that has already been parsed from the $templateFile
* through {@link SSViewer}
* @return string HTML content augmented with the requirements tags
* @throws ValidationException
*/
public function includeInHTML($content): string
{
if (func_num_args() > 1) {
Deprecation::notice(
'5.0',
'$templateFile argument is deprecated. includeInHTML takes a sole $content parameter now.'
);
$content = func_get_arg(1);
}
// Skip if content isn't injectable, or there is nothing to inject
if (!$this->shouldContinue($content)) {
return $content;
}
$requirements = [];
$jsRequirements = [];
// Combine files - updates $this->javascript and $this->css
$this->processCombinedFiles();
$jsRequirements = $this->getJSRequirements($jsRequirements);
$requirements = $this->getCSSRequirements($requirements);
$requirements = $this->getHeadTags($requirements);
return $this->insertContent($content, $requirements, $jsRequirements);
}
/**
* @param $content
* @return bool
*/
protected function shouldContinue($content): bool
{
$tagsAvailable = preg_match('#</head\b#', $content);
$hasFiles = count($this->css) ||
count($this->javascript) ||
count($this->customCSS) ||
count($this->customScript) ||
count($this->customHeadTags);
return $tagsAvailable && $hasFiles;
}
/**
* @param array $jsRequirements
* @return string
* @throws ValidationException
*/
protected function getJSRequirements(array $jsRequirements): string
{
// Script tags for js links
foreach ($this->getJavascript() as $file => $attributes) {
$path = $this->pathForFile($file);
$jsRequirements = $this->getJsBuilder()->buildTags($file, $attributes, $jsRequirements, $path);
}
return implode(PHP_EOL, $this->getJsBuilder()->getCustomTags($jsRequirements));
}
/**
* @param array $requirements
* @return array
* @throws ValidationException
*/
protected function getCSSRequirements(array $requirements): array
{
// CSS file links
foreach ($this->getCSS() as $file => $attributes) {
$path = $this->pathForFile($file);
$requirements = $this->getCssBuilder()->buildTags($file, $attributes, $requirements, $path);
}
return $this->getCssBuilder()->getCustomTags($requirements);
}
/**
* @param array $requirements
* @return string
*/
protected function getHeadTags(array $requirements = []): string
{
$this->getCssBuilder()->getHeadTags($requirements);
$this->getJsBuilder()->getHeadTags($requirements);
foreach ($this->getCustomHeadTags() as $customHeadTag) {
$requirements[] = $customHeadTag;
}
return implode(PHP_EOL, $requirements);
}
/**
* @param $content
* @param string $requirements
* @param string $jsRequirements
* @return string
*/
protected function insertContent($content, string $requirements, string $jsRequirements): string
{
$requirements .= PHP_EOL;
$jsRequirements .= PHP_EOL;
// Inject CSS into head
$content = $this->insertTagsIntoHead($requirements, $content);
// Inject scripts
if ($this->getForceJSToBottom()) {
$content = $this->insertScriptsAtBottom($jsRequirements, $content);
} elseif ($this->getWriteJavascriptToBody()) {
$content = $this->insertScriptsIntoBody($jsRequirements, $content);
} else {
$content = $this->insertTagsIntoHead($jsRequirements, $content);
}
return $content;
}
}