568 lines
16 KiB
PHP
568 lines
16 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App;
|
|
|
|
use App\Traits\RequestAwareTrait;
|
|
use App\Utilities\Json;
|
|
use GuzzleHttp\Psr7\Uri;
|
|
use InvalidArgumentException;
|
|
use Psr\Http\Message\ResponseInterface;
|
|
|
|
use function base64_encode;
|
|
use function is_array;
|
|
use function is_callable;
|
|
use function preg_replace;
|
|
use function random_bytes;
|
|
|
|
/**
|
|
* Asset management helper class.
|
|
* Inspired by Asseter by Adam Banaszkiewicz: https://github.com/requtize
|
|
* @link https://github.com/requtize/assetter
|
|
*/
|
|
class Assets
|
|
{
|
|
use RequestAwareTrait;
|
|
|
|
/** @var array<string, array> Known libraries loaded in initialization. */
|
|
protected array $libraries = [];
|
|
|
|
/** @var array<string, string> An optional array lookup for versioned files. */
|
|
protected array $versioned_files = [];
|
|
|
|
/** @var array<string, array> Loaded libraries. */
|
|
protected array $loaded = [];
|
|
|
|
/** @var bool Whether the current loaded libraries have been sorted by order. */
|
|
protected bool $is_sorted = true;
|
|
|
|
/** @var string A randomly generated number-used-once (nonce) for inline CSP. */
|
|
protected string $csp_nonce;
|
|
|
|
/** @var array The loaded domains that should be included in the CSP header. */
|
|
protected array $csp_domains;
|
|
|
|
public function __construct(
|
|
protected Environment $environment,
|
|
array $libraries,
|
|
) {
|
|
foreach ($libraries as $library_name => $library) {
|
|
$this->addLibrary($library, $library_name);
|
|
}
|
|
|
|
$versioned_files = Json::loadFromFile($environment->getBaseDirectory() . '/web/static/assets.json');
|
|
$this->versioned_files = $versioned_files;
|
|
|
|
$vueComponents = Json::loadFromFile($environment->getBaseDirectory() . '/web/static/webpack.json');
|
|
$this->addVueComponents($vueComponents);
|
|
|
|
$this->csp_nonce = (string)preg_replace('/[^A-Za-z\d+\/=]/', '', base64_encode(random_bytes(18)));
|
|
$this->csp_domains = [];
|
|
}
|
|
|
|
protected function addVueComponents(array $vueComponents = []): void
|
|
{
|
|
if (!empty($vueComponents['entrypoints'])) {
|
|
foreach ($vueComponents['entrypoints'] as $componentName => $componentDeps) {
|
|
$componentName = 'Vue_' . $componentName;
|
|
|
|
$library = $this->libraries[$componentName] ?? [
|
|
'order' => 10,
|
|
'require' => [],
|
|
'files' => [],
|
|
];
|
|
|
|
foreach ($componentDeps['assets']['js'] as $componentDep) {
|
|
$library['files']['js'][] = [
|
|
'src' => $componentDep,
|
|
];
|
|
}
|
|
|
|
$this->addLibrary($library, $componentName);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add a library to the collection.
|
|
*
|
|
* @param array $data Array with asset data.
|
|
* @param string|null $library_name
|
|
*/
|
|
public function addLibrary(array $data, string $library_name = null): self
|
|
{
|
|
$library_name = $library_name ?? uniqid('', false);
|
|
|
|
$this->libraries[$library_name] = [
|
|
'name' => $library_name,
|
|
'order' => $data['order'] ?? 0,
|
|
'files' => $data['files'] ?? [],
|
|
'inline' => $data['inline'] ?? [],
|
|
'require' => $data['require'] ?? [],
|
|
'replace' => $data['replace'] ?? [],
|
|
];
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* @param string $library_name
|
|
*
|
|
* @return mixed[]|null
|
|
*/
|
|
public function getLibrary(string $library_name): ?array
|
|
{
|
|
return $this->libraries[$library_name] ?? null;
|
|
}
|
|
|
|
/**
|
|
* Returns the randomly generated nonce for inline CSP for this request.
|
|
*/
|
|
public function getCspNonce(): string
|
|
{
|
|
return $this->csp_nonce;
|
|
}
|
|
|
|
/**
|
|
* Returns the list of approved domains for CSP header inclusion.
|
|
*
|
|
* @return string[]
|
|
*/
|
|
public function getCspDomains(): array
|
|
{
|
|
return $this->csp_domains;
|
|
}
|
|
|
|
/**
|
|
* Add a single javascript file.
|
|
*
|
|
* @param array|string $js_script
|
|
*/
|
|
public function addJs(array|string $js_script): self
|
|
{
|
|
$this->load(
|
|
[
|
|
'order' => 100,
|
|
'files' => [
|
|
'js' => [
|
|
(is_array($js_script)) ? $js_script : ['src' => $js_script],
|
|
],
|
|
],
|
|
]
|
|
);
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Loads assets from given name or array definition.
|
|
*
|
|
* @param mixed $data Name or array definition of library/asset.
|
|
*/
|
|
public function load(mixed $data): self
|
|
{
|
|
if (is_array($data)) {
|
|
$item = [
|
|
'name' => $data['name'] ?? uniqid('', false),
|
|
'order' => $data['order'] ?? 0,
|
|
'files' => $data['files'] ?? [],
|
|
'inline' => $data['inline'] ?? [],
|
|
'require' => $data['require'] ?? [],
|
|
'replace' => $data['replace'] ?? [],
|
|
];
|
|
} elseif (isset($this->libraries[$data])) {
|
|
$item = $this->libraries[$data];
|
|
} else {
|
|
throw new InvalidArgumentException(sprintf('Library %s not found!', $data));
|
|
}
|
|
|
|
/** @var string $name */
|
|
$name = $item['name'];
|
|
|
|
// Check if a library is "replaced" by other libraries already loaded.
|
|
$is_replaced = false;
|
|
foreach ($this->loaded as $loaded_item) {
|
|
if (!empty($loaded_item['replace']) && in_array($name, (array)$loaded_item['replace'], true)) {
|
|
$is_replaced = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!$is_replaced && !isset($this->loaded[$name])) {
|
|
if (!empty($item['replace'])) {
|
|
foreach ((array)$item['replace'] as $replace_name) {
|
|
$this->unload($replace_name);
|
|
}
|
|
}
|
|
|
|
if (!empty($item['require'])) {
|
|
foreach ((array)$item['require'] as $require_name) {
|
|
$this->load($require_name);
|
|
}
|
|
}
|
|
|
|
$this->loaded[$name] = $item;
|
|
$this->is_sorted = false;
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Unload a given library if it's already loaded.
|
|
*
|
|
* @param string $name
|
|
*/
|
|
public function unload(string $name): self
|
|
{
|
|
if (isset($this->loaded[$name])) {
|
|
unset($this->loaded[$name]);
|
|
$this->is_sorted = false;
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Add a single javascript inline script.
|
|
*
|
|
* @param array|string $js_script
|
|
* @param int $order
|
|
*/
|
|
public function addInlineJs(array|string $js_script, int $order = 100): self
|
|
{
|
|
$this->load(
|
|
[
|
|
'order' => $order,
|
|
'inline' => [
|
|
'js' => (is_array($js_script)) ? $js_script : [$js_script],
|
|
],
|
|
]
|
|
);
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function addVueRender(string $name, string $elementId, array $props = []): self
|
|
{
|
|
$this->load($name);
|
|
|
|
$nameWithoutPrefix = str_replace('Vue_', '', $name);
|
|
$propsJson = json_encode($props, JSON_THROW_ON_ERROR);
|
|
|
|
$this->addInlineJs(
|
|
<<<JS
|
|
let {$name};
|
|
$(function () {
|
|
{$name} = {$nameWithoutPrefix}.default('{$elementId}', {$propsJson});
|
|
});
|
|
JS
|
|
);
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Add a single CSS file.
|
|
*
|
|
* @param array|string $css_script
|
|
* @param int $order
|
|
*/
|
|
public function addCss(array|string $css_script, int $order = 100): self
|
|
{
|
|
$this->load(
|
|
[
|
|
'order' => $order,
|
|
'files' => [
|
|
'css' => [
|
|
(is_array($css_script)) ? $css_script : ['src' => $css_script],
|
|
],
|
|
],
|
|
]
|
|
);
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Add a single inline CSS file[s].
|
|
*
|
|
* @param array|string $css_script
|
|
*/
|
|
public function addInlineCss(array|string $css_script): self
|
|
{
|
|
$this->load(
|
|
[
|
|
'order' => 100,
|
|
'inline' => [
|
|
'css' => (is_array($css_script)) ? $css_script : [$css_script],
|
|
],
|
|
]
|
|
);
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Returns all CSS includes and inline styles.
|
|
*
|
|
* @return string HTML tags as string.
|
|
*/
|
|
public function css(): string
|
|
{
|
|
$this->sort();
|
|
|
|
$result = [];
|
|
foreach ($this->loaded as $item) {
|
|
if (!empty($item['files']['css'])) {
|
|
foreach ($item['files']['css'] as $file) {
|
|
$attributes = $this->resolveAttributes(
|
|
$file,
|
|
[
|
|
'rel' => 'stylesheet',
|
|
'type' => 'text/css',
|
|
]
|
|
);
|
|
|
|
$key = $attributes['href'];
|
|
if (isset($result[$key])) {
|
|
continue;
|
|
}
|
|
|
|
$result[$key] = '<link ' . implode(' ', $this->compileAttributes($attributes)) . ' />';
|
|
}
|
|
}
|
|
|
|
if (!empty($item['inline']['css'])) {
|
|
foreach ($item['inline']['css'] as $inline) {
|
|
if (!empty($inline)) {
|
|
$result[] = sprintf(
|
|
'<style type="text/css" nonce="%s">%s%s</style>',
|
|
$this->csp_nonce,
|
|
"\n",
|
|
$inline
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return implode("\n", $result) . "\n";
|
|
}
|
|
|
|
/**
|
|
* Returns all script include tags.
|
|
*
|
|
* @return string HTML tags as string.
|
|
*/
|
|
public function js(): string
|
|
{
|
|
$this->sort();
|
|
|
|
$result = [];
|
|
foreach ($this->loaded as $item) {
|
|
if (!empty($item['files']['js'])) {
|
|
foreach ($item['files']['js'] as $file) {
|
|
$attributes = $this->resolveAttributes(
|
|
$file,
|
|
[
|
|
'type' => 'text/javascript',
|
|
]
|
|
);
|
|
|
|
$key = $attributes['src'];
|
|
if (isset($result[$key])) {
|
|
continue;
|
|
}
|
|
|
|
$result[$key] = '<script ' . implode(' ', $this->compileAttributes($attributes)) . '></script>';
|
|
}
|
|
}
|
|
}
|
|
|
|
return implode("\n", $result) . "\n";
|
|
}
|
|
|
|
/**
|
|
* Return any inline JavaScript.
|
|
*/
|
|
public function inlineJs(): string
|
|
{
|
|
$this->sort();
|
|
|
|
$result = [];
|
|
foreach ($this->loaded as $item) {
|
|
if (!empty($item['inline']['js'])) {
|
|
foreach ($item['inline']['js'] as $inline) {
|
|
if (is_callable($inline)) {
|
|
$inline = $inline($this->request);
|
|
}
|
|
|
|
if (!empty($inline)) {
|
|
$result[] = sprintf(
|
|
'<script type="text/javascript" nonce="%s">%s%s</script>',
|
|
$this->csp_nonce,
|
|
"\n",
|
|
$inline
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return implode("\n", $result) . "\n";
|
|
}
|
|
|
|
/**
|
|
* Sort the list of loaded libraries.
|
|
*/
|
|
protected function sort(): void
|
|
{
|
|
if (!$this->is_sorted) {
|
|
uasort(
|
|
$this->loaded,
|
|
static function ($a, $b): int {
|
|
return $a['order'] <=> $b['order']; // SPACESHIP!
|
|
}
|
|
);
|
|
|
|
$this->is_sorted = true;
|
|
}
|
|
}
|
|
|
|
protected function resolveAttributes(array $file, array $defaults): array
|
|
{
|
|
if (isset($file['src'])) {
|
|
$defaults['src'] = $this->getUrl($file['src']);
|
|
unset($file['src']);
|
|
}
|
|
|
|
if (isset($file['href'])) {
|
|
$defaults['href'] = $this->getUrl($file['href']);
|
|
unset($file['href']);
|
|
}
|
|
|
|
if (isset($file['integrity'])) {
|
|
$defaults['crossorigin'] = 'anonymous';
|
|
}
|
|
|
|
return array_merge($defaults, $file);
|
|
}
|
|
|
|
/**
|
|
* Build the proper include tag for a JS/CSS include.
|
|
*
|
|
* @param array $attributes
|
|
*
|
|
* @return string[]
|
|
*/
|
|
protected function compileAttributes(array $attributes): array
|
|
{
|
|
$compiled_attributes = [];
|
|
foreach ($attributes as $attr_key => $attr_val) {
|
|
// Check for attributes like "defer"
|
|
if ($attr_val === true) {
|
|
$compiled_attributes[] = $attr_key;
|
|
} else {
|
|
$compiled_attributes[] = $attr_key . '="' . $attr_val . '"';
|
|
}
|
|
}
|
|
|
|
return $compiled_attributes;
|
|
}
|
|
|
|
/**
|
|
* @return string[] The paths to all currently loaded files.
|
|
*/
|
|
public function getLoadedFiles(): array
|
|
{
|
|
$this->sort();
|
|
|
|
$result = [];
|
|
foreach ($this->loaded as $item) {
|
|
if (!empty($item['files']['js'])) {
|
|
foreach ($item['files']['js'] as $file) {
|
|
if (isset($file['src'])) {
|
|
$result[] = $this->getUrl($file['src']);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!empty($item['files']['css'])) {
|
|
foreach ($item['files']['css'] as $file) {
|
|
if (isset($file['href'])) {
|
|
$result[] = $this->getUrl($file['href']);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Resolve the URI of the resource, whether local or remote/CDN-based.
|
|
*
|
|
* @param string $resource_uri
|
|
*
|
|
* @return string The resolved resource URL.
|
|
*/
|
|
public function getUrl(string $resource_uri): string
|
|
{
|
|
if (isset($this->versioned_files[$resource_uri])) {
|
|
$resource_uri = $this->versioned_files[$resource_uri];
|
|
}
|
|
|
|
if (str_starts_with($resource_uri, 'http')) {
|
|
$this->addDomainToCsp($resource_uri);
|
|
return $resource_uri;
|
|
}
|
|
|
|
if (str_starts_with($resource_uri, '/')) {
|
|
return $resource_uri;
|
|
}
|
|
|
|
return $this->environment->getAssetUrl() . '/' . $resource_uri;
|
|
}
|
|
|
|
/**
|
|
* Add the loaded domain to the full list of CSP-approved domains.
|
|
*
|
|
* @param string $src
|
|
*/
|
|
protected function addDomainToCsp(string $src): void
|
|
{
|
|
$uri = new Uri($src);
|
|
|
|
$domain = $uri->getScheme() . '://' . $uri->getHost();
|
|
if (!isset($this->csp_domains[$domain])) {
|
|
$this->csp_domains[$domain] = $domain;
|
|
}
|
|
}
|
|
|
|
public function writeCsp(ResponseInterface $response): ResponseInterface
|
|
{
|
|
$csp = [];
|
|
if (null !== $this->request && 'https' === $this->request->getUri()->getScheme()) {
|
|
$csp[] = 'upgrade-insecure-requests';
|
|
}
|
|
|
|
// CSP JavaScript policy
|
|
// Note: unsafe-eval included for Vue template compiling
|
|
$cspScriptSrc = $this->getCspDomains();
|
|
$cspScriptSrc[] = "'self'";
|
|
$cspScriptSrc[] = "'unsafe-eval'";
|
|
$cspScriptSrc[] = "'nonce-" . $this->getCspNonce() . "'";
|
|
$csp[] = 'script-src ' . implode(' ', $cspScriptSrc);
|
|
|
|
$cspWorkerSrc = [];
|
|
$cspWorkerSrc[] = "blob:";
|
|
$cspWorkerSrc[] = "'self'";
|
|
|
|
$csp[] = 'worker-src ' . implode(' ', $cspWorkerSrc);
|
|
|
|
return $response->withHeader('Content-Security-Policy', implode('; ', $csp));
|
|
}
|
|
}
|