| Current Path : /var/www/surf/TYPO3/vendor/typo3/cms-core/Classes/Resource/ |
| Current File : /var/www/surf/TYPO3/vendor/typo3/cms-core/Classes/Resource/ResourceCompressor.php |
<?php
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Core\Resource;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Log\LogManager;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
use TYPO3\CMS\Core\Utility\PathUtility;
use TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController;
/**
* This class merges and compresses CSS and JavaScript files of the TYPO3 Frontend.
* It should never be used for TYPO3 Backend.
*/
class ResourceCompressor
{
/**
* @var string
*/
protected $targetDirectory = 'typo3temp/assets/compressed/';
/**
* @var string
*/
protected $rootPath = '';
/**
* gzipped versions are only created if $TYPO3_CONF_VARS['BE' or 'FE']['compressionLevel'] is set
*
* @var bool
*/
protected $createGzipped = false;
protected string $gzipFileExtension = '.gz';
/**
* @var int
*/
protected $gzipCompressionLevel = -1;
/**
* @var string
*/
protected $htaccessTemplate = '<FilesMatch "\\.(js|css)(\\.gz)?$">
<IfModule mod_expires.c>
ExpiresActive on
ExpiresDefault "access plus 7 days"
</IfModule>
FileETag MTime Size
</FilesMatch>';
protected bool $initialized = false;
protected function initialize(): void
{
if ($this->initialized) {
return;
}
// we check for existence of our targetDirectory
if (!is_dir(Environment::getPublicPath() . '/' . $this->targetDirectory)) {
GeneralUtility::mkdir_deep(Environment::getPublicPath() . '/' . $this->targetDirectory);
}
// if enabled, we check whether we should auto-create the .htaccess file
if ($GLOBALS['TYPO3_CONF_VARS']['SYS']['generateApacheHtaccess']) {
// check whether .htaccess exists
$htaccessPath = Environment::getPublicPath() . '/' . $this->targetDirectory . '.htaccess';
if (!file_exists($htaccessPath)) {
GeneralUtility::writeFile($htaccessPath, $this->htaccessTemplate);
}
}
// decide whether we should create gzipped versions or not
$compressionLevel = $GLOBALS['TYPO3_CONF_VARS']['FE']['compressionLevel'];
// we need zlib for gzencode()
if (extension_loaded('zlib') && $compressionLevel) {
$this->createGzipped = true;
// $compressionLevel can also be TRUE
if (MathUtility::canBeInterpretedAsInteger($compressionLevel)) {
$this->gzipCompressionLevel = (int)$compressionLevel;
}
}
$this->rootPath = Environment::getPublicPath() . '/';
$this->initialized = true;
}
/**
* Concatenates the Stylesheet files
*
* @param array $cssFiles CSS files to process
* @return array CSS files
*/
public function concatenateCssFiles(array $cssFiles)
{
$this->initialize();
$filesToIncludeByType = ['all' => []];
foreach ($cssFiles as $key => $fileOptions) {
// no concatenation allowed for this file, so continue
if (!empty($fileOptions['excludeFromConcatenation'])) {
continue;
}
$filenameFromMainDir = $this->getFilenameFromMainDir($fileOptions['file']);
$type = isset($fileOptions['media']) ? strtolower($fileOptions['media']) : 'all';
if (!isset($filesToIncludeByType[$type])) {
$filesToIncludeByType[$type] = [];
}
if (!empty($fileOptions['forceOnTop'])) {
array_unshift($filesToIncludeByType[$type], $filenameFromMainDir);
} else {
$filesToIncludeByType[$type][] = $filenameFromMainDir;
}
// remove the file from the incoming file array
unset($cssFiles[$key]);
}
foreach ($filesToIncludeByType as $mediaOption => $filesToInclude) {
if (empty($filesToInclude)) {
continue;
}
$targetFile = $this->createMergedCssFile($filesToInclude);
$concatenatedOptions = [
'file' => $targetFile,
'rel' => 'stylesheet',
'media' => $mediaOption,
'compress' => true,
'excludeFromConcatenation' => true,
'forceOnTop' => false,
'allWrap' => '',
];
// place the merged stylesheet on top of the stylesheets
$cssFiles = array_merge($cssFiles, [$targetFile => $concatenatedOptions]);
}
return $cssFiles;
}
/**
* Concatenates the JavaScript files
*
* @param array $jsFiles JavaScript files to process
* @return array JS files
*/
public function concatenateJsFiles(array $jsFiles)
{
$this->initialize();
$concatenatedJsFileIsAsync = false;
$allFilesToConcatenateAreAsync = true;
$filesToInclude = [];
foreach ($jsFiles as $key => $fileOptions) {
// invalid section found or no concatenation allowed, so continue
if (empty($fileOptions['section']) || !empty($fileOptions['excludeFromConcatenation']) || !empty($fileOptions['nomodule']) || !empty($fileOptions['defer'])) {
continue;
}
if (!isset($filesToInclude[$fileOptions['section']])) {
$filesToInclude[$fileOptions['section']] = [];
}
$filenameFromMainDir = $this->getFilenameFromMainDir($fileOptions['file']);
if (!empty($fileOptions['forceOnTop'])) {
array_unshift($filesToInclude[$fileOptions['section']], $filenameFromMainDir);
} else {
$filesToInclude[$fileOptions['section']][] = $filenameFromMainDir;
}
if ($fileOptions['async'] ?? false) {
$concatenatedJsFileIsAsync = true;
} else {
$allFilesToConcatenateAreAsync = false;
}
// remove the file from the incoming file array
unset($jsFiles[$key]);
}
if (!empty($filesToInclude)) {
$defaultTypeAttributeForJavaScript = $this->getJavaScriptFileType();
foreach ($filesToInclude as $section => $files) {
$targetFile = $this->createMergedJsFile($files);
$concatenatedOptions = [
'file' => $targetFile,
'type' => $defaultTypeAttributeForJavaScript,
'section' => $section,
'compress' => true,
'excludeFromConcatenation' => true,
'forceOnTop' => false,
'allWrap' => '',
'async' => $concatenatedJsFileIsAsync && $allFilesToConcatenateAreAsync,
];
// place the merged javascript on top of the JS files
$jsFiles = array_merge([$targetFile => $concatenatedOptions], $jsFiles);
}
}
return $jsFiles;
}
/**
* Creates a merged CSS file
*
* @param array $filesToInclude Files which should be merged, paths relative to root path
* @return mixed Filename of the merged file
*/
protected function createMergedCssFile(array $filesToInclude)
{
return $this->createMergedFile($filesToInclude, 'css');
}
/**
* Creates a merged JS file
*
* @param array $filesToInclude Files which should be merged, paths relative to root path
* @return mixed Filename of the merged file
*/
protected function createMergedJsFile(array $filesToInclude)
{
return $this->createMergedFile($filesToInclude, 'js');
}
/**
* Creates a merged file with given file type
*
* @param array $filesToInclude Files which should be merged, paths relative to root path
* @param string $type File type
*
* @throws \InvalidArgumentException
* @return mixed Filename of the merged file
*/
protected function createMergedFile(array $filesToInclude, $type = 'css')
{
// Get file type
$type = strtolower(trim($type, '. '));
if (empty($type)) {
throw new \InvalidArgumentException('No valid file type given for files to be merged.', 1308957498);
}
// we add up the filenames, filemtimes and filesizes to later build a checksum over
// it and include it in the temporary file name
$unique = '';
foreach ($filesToInclude as $key => $filename) {
if (GeneralUtility::isValidUrl($filename)) {
// check if it is possibly a local file with fully qualified URL
if (GeneralUtility::isOnCurrentHost($filename) &&
str_starts_with(
$filename,
$GLOBALS['TYPO3_REQUEST']->getAttribute('normalizedParams')->getSiteUrl()
)
) {
// attempt to turn it into a local file path
$localFilename = substr($filename, strlen($GLOBALS['TYPO3_REQUEST']->getAttribute('normalizedParams')->getSiteUrl()));
if (@is_file(GeneralUtility::resolveBackPath($this->rootPath . $localFilename))) {
$filesToInclude[$key] = $localFilename;
} else {
$filesToInclude[$key] = $this->retrieveExternalFile($filename);
}
} else {
$filesToInclude[$key] = $this->retrieveExternalFile($filename);
}
$filename = $filesToInclude[$key];
}
$filenameAbsolute = GeneralUtility::resolveBackPath($this->rootPath . $filename);
if (@file_exists($filenameAbsolute)) {
$fileStatus = stat($filenameAbsolute);
$unique .= $filenameAbsolute . $fileStatus['mtime'] . $fileStatus['size'];
} else {
$unique .= $filenameAbsolute;
}
}
$targetFile = $this->targetDirectory . 'merged-' . md5($unique) . '.' . $type;
// if the file doesn't already exist, we create it
if (!file_exists(Environment::getPublicPath() . '/' . $targetFile)) {
$concatenated = '';
// concatenate all the files together
foreach ($filesToInclude as $filename) {
$filenameAbsolute = GeneralUtility::resolveBackPath($this->rootPath . $filename);
$filename = PathUtility::stripPathSitePrefix($filenameAbsolute);
$contents = (string)file_get_contents($filenameAbsolute);
// remove any UTF-8 byte order mark (BOM) from files
if (str_starts_with($contents, "\xEF\xBB\xBF")) {
$contents = substr($contents, 3);
}
// only fix paths if files aren't already in typo3temp (already processed)
if ($type === 'css' && !str_starts_with($filename, $this->targetDirectory)) {
$contents = $this->cssFixRelativeUrlPaths($contents, $filename);
}
$concatenated .= LF . $contents;
}
// move @charset, @import and @namespace statements to top of new file
if ($type === 'css') {
$concatenated = $this->cssFixStatements($concatenated);
}
GeneralUtility::writeFile(Environment::getPublicPath() . '/' . $targetFile, $concatenated);
}
return $targetFile;
}
/**
* Compress multiple css files
*
* @param array $cssFiles The files to compress (array key = filename), relative to requested page
* @return array The CSS files after compression (array key = new filename), relative to requested page
*/
public function compressCssFiles(array $cssFiles)
{
$this->initialize();
$filesAfterCompression = [];
foreach ($cssFiles as $key => $fileOptions) {
// if compression is enabled
if ($fileOptions['compress']) {
$filename = $this->compressCssFile($fileOptions['file']);
$fileOptions['compress'] = false;
$fileOptions['file'] = $filename;
$filesAfterCompression[$filename] = $fileOptions;
} else {
$filesAfterCompression[$key] = $fileOptions;
}
}
return $filesAfterCompression;
}
/**
* Compresses a CSS file
*
* Options:
* baseDirectories If set, only include files below one of the base directories
*
* removes comments and whitespaces
* Adopted from https://github.com/drupal/drupal/blob/8.0.x/core/lib/Drupal/Core/Asset/CssOptimizer.php
*
* @param string $filename Source filename, relative to requested page
* @return string Compressed filename, relative to requested page
*/
public function compressCssFile($filename)
{
$this->initialize();
// generate the unique name of the file
$filenameAbsolute = GeneralUtility::resolveBackPath($this->rootPath . $this->getFilenameFromMainDir($filename));
if (@file_exists($filenameAbsolute)) {
$fileStatus = stat($filenameAbsolute);
$unique = $filenameAbsolute . $fileStatus['mtime'] . $fileStatus['size'];
} else {
$unique = $filenameAbsolute;
}
// make sure it is again the full filename
$filename = PathUtility::stripPathSitePrefix($filenameAbsolute);
$pathinfo = PathUtility::pathinfo($filenameAbsolute);
$targetFile = $this->targetDirectory . $pathinfo['filename'] . '-' . md5($unique) . '.css';
// only create it, if it doesn't exist, yet
if (!file_exists(Environment::getPublicPath() . '/' . $targetFile) || $this->createGzipped && !file_exists(Environment::getPublicPath() . '/' . $targetFile . $this->gzipFileExtension)) {
$contents = $this->compressCssString((string)file_get_contents($filenameAbsolute));
if (!str_contains($filename, $this->targetDirectory)) {
$contents = $this->cssFixRelativeUrlPaths($contents, $filename);
}
$this->writeFileAndCompressed($targetFile, $contents);
}
return $this->returnFileReference($targetFile);
}
/**
* Compress multiple javascript files
*
* @param array $jsFiles The files to compress (array key = filename), relative to requested page
* @return array The js files after compression (array key = new filename), relative to requested page
*/
public function compressJsFiles(array $jsFiles)
{
$this->initialize();
$filesAfterCompression = [];
foreach ($jsFiles as $fileName => $fileOptions) {
// If compression is enabled
if ($fileOptions['compress']) {
$compressedFilename = $this->compressJsFile($fileOptions['file']);
$fileOptions['compress'] = false;
$fileOptions['file'] = $compressedFilename;
$filesAfterCompression[$compressedFilename] = $fileOptions;
} else {
$filesAfterCompression[$fileName] = $fileOptions;
}
}
return $filesAfterCompression;
}
/**
* Compresses a javascript file
*
* @param string $filename Source filename, relative to requested page
* @return string Filename of the compressed file, relative to requested page
*/
public function compressJsFile($filename)
{
$this->initialize();
// generate the unique name of the file
$filenameAbsolute = GeneralUtility::resolveBackPath($this->rootPath . $this->getFilenameFromMainDir($filename));
if (@file_exists($filenameAbsolute)) {
$fileStatus = stat($filenameAbsolute);
$unique = $filenameAbsolute . $fileStatus['mtime'] . $fileStatus['size'];
} else {
$unique = $filenameAbsolute;
}
$pathinfo = PathUtility::pathinfo($filename);
$targetFile = $this->targetDirectory . $pathinfo['filename'] . '-' . md5($unique) . '.js';
// only create it, if it doesn't exist, yet
if (!file_exists(Environment::getPublicPath() . '/' . $targetFile) || $this->createGzipped && !file_exists(Environment::getPublicPath() . '/' . $targetFile . $this->gzipFileExtension)) {
$contents = (string)file_get_contents($filenameAbsolute);
$this->writeFileAndCompressed($targetFile, $contents);
}
return $this->returnFileReference($targetFile);
}
/**
* Finds the relative path to a file, relative to the root path.
*
* @param string $filename the name of the file
* @return string the path to the file relative to the root path ($this->rootPath)
*/
protected function getFilenameFromMainDir($filename)
{
/*
* The various paths may have those values (e.g. if TYPO3 is installed in a subdir)
* - docRoot = /var/www/html/
* - Environment::getPublicPath() = /var/www/html/sites/site1/
* - $this->rootPath = /var/www/html/sites/site1/typo3
*
* The file names passed into this function may be either:
* - relative to $this->rootPath
* - relative to Environment::getPublicPath()
* - relative to docRoot
*/
$docRoot = GeneralUtility::getIndpEnv('TYPO3_DOCUMENT_ROOT');
$fileNameWithoutSlash = ltrim($filename, '/');
// if the file is an absolute reference within the docRoot
$absolutePath = $docRoot . '/' . $fileNameWithoutSlash;
// If the $filename stems from a call to PathUtility::getAbsoluteWebPath() it has a leading slash,
// hence isAbsolutePath() results in true, which is obviously wrong. Check file existence to be sure.
// Calling is_file without @ for a path starting with '../' causes a PHP Warning when using open_basedir restriction
if (PathUtility::isAbsolutePath($filename) && @is_file($filename)) {
$absolutePath = $filename;
}
if (@is_file($absolutePath)) {
$absolutePath = Environment::getPublicPath() . '/' . PathUtility::getAbsoluteWebPath($absolutePath, false);
if (str_starts_with($absolutePath, $this->rootPath)) {
// the path is within the current root path, simply strip rootPath off
return substr($absolutePath, strlen($this->rootPath));
}
// the path is not within the root path, strip off the site path, the remaining logic below
// takes care about adjusting the path correctly.
$filename = substr($absolutePath, strlen(Environment::getPublicPath() . '/'));
}
// if the file exists in the root path, just return the $filename
if (is_file($this->rootPath . $fileNameWithoutSlash)) {
return $fileNameWithoutSlash;
}
// build the file path relative to the public web path
if (PathUtility::isExtensionPath($filename)) {
$file = Environment::getPublicPath() . '/' . PathUtility::getPublicResourceWebPath($filename, false);
} else {
$file = Environment::getPublicPath() . '/' . $filename;
}
// check if the file exists, and if so, return the path relative to current PHP script
if (is_file($file)) {
return rtrim((string)PathUtility::getRelativePathTo($file), '/');
}
// none of above conditions were met, fallback to default behaviour
return $filename;
}
/**
* Decides whether a file comes from one of the baseDirectories
*
* @param string $filename Filename
* @param array $baseDirectories Base directories
* @return bool File belongs to a base directory or not
*/
protected function checkBaseDirectory($filename, array $baseDirectories)
{
foreach ($baseDirectories as $baseDirectory) {
// check, if $filename starts with base directory
if (str_starts_with($filename, $baseDirectory)) {
return true;
}
}
return false;
}
protected function cssFixRelativeUrlPaths(string $contents, string $filename): string
{
$newDir = '../../../' . PathUtility::dirname($filename) . '/';
return $this->getPathFixer()->fixRelativeUrlPaths($contents, $newDir);
}
/**
* Moves @charset, @import and @namespace statements to the top of
* the content, because they must occur before all other CSS rules
*
* @param string $contents Data to process
* @return string Processed data
*/
protected function cssFixStatements($contents)
{
$matches = [];
$comment = LF . '/* moved by compressor */' . LF;
// nothing to do, so just return contents
if (stripos($contents, '@charset') === false && stripos($contents, '@import') === false && stripos($contents, '@namespace') === false) {
return $contents;
}
$regex = '/@(charset|import|namespace)\\s*(url)?\\s*\\(?\\s*["\']?[^"\'\\)]+["\']?\\s*\\)?\\s*;/i';
preg_match_all($regex, $contents, $matches);
if (!empty($matches[0])) {
// Ensure correct order of @charset, @namespace and @import
$charset = '';
$namespaces = [];
$imports = [];
foreach ($matches[1] as $index => $keyword) {
switch ($keyword) {
case 'charset':
if (empty($charset)) {
$charset = $matches[0][$index];
}
break;
case 'namespace':
$namespaces[] = $matches[0][$index];
break;
case 'import':
$imports[] = $matches[0][$index];
break;
}
}
$namespaces = !empty($namespaces) ? implode('', $namespaces) . $comment : '';
$imports = !empty($imports) ? implode('', $imports) . $comment : '';
// remove existing statements
$contents = str_replace($matches[0], '', $contents);
// add statements to the top of contents in the order they occur in original file
$contents =
$charset
. $comment
. $namespaces
. $imports
. trim($contents);
}
return $contents;
}
/**
* Writes $contents into file $filename together with a gzipped version into $filename.gz (gzipFileExtension)
*
* @param string $filename Target filename
* @param string $contents File contents
*/
protected function writeFileAndCompressed($filename, $contents)
{
// write uncompressed file
GeneralUtility::writeFile(Environment::getPublicPath() . '/' . $filename, $contents);
if ($this->createGzipped) {
// create compressed version
GeneralUtility::writeFile(Environment::getPublicPath() . '/' . $filename . $this->gzipFileExtension, (string)gzencode($contents, $this->gzipCompressionLevel));
}
}
/**
* Decides whether a client can deal with gzipped content or not and returns the according file name,
* based on HTTP_ACCEPT_ENCODING
*
* @param string $filename File name
* @return string $filename suffixed with '.gz' or not - dependent on HTTP_ACCEPT_ENCODING
*/
protected function returnFileReference($filename)
{
// if the client accepts gzip and we can create gzipped files, we give him compressed versions
if ($this->createGzipped && str_contains(GeneralUtility::getIndpEnv('HTTP_ACCEPT_ENCODING'), 'gzip')) {
$filename .= $this->gzipFileExtension;
}
return PathUtility::getRelativePath($this->rootPath, Environment::getPublicPath() . '/') . $filename;
}
/**
* Retrieves an external file and stores it locally.
*
* @param string $url
* @return string Temporary local filename for the externally-retrieved file
*/
protected function retrieveExternalFile($url)
{
$externalContent = GeneralUtility::getUrl($url);
$filename = $this->targetDirectory . 'external-' . md5($url);
// Write only if file does not exist OR md5 of the content is not the same as fetched one
if (!file_exists(Environment::getPublicPath() . '/' . $filename)
|| !hash_equals(md5((string)file_get_contents(Environment::getPublicPath() . '/' . $filename)), md5($externalContent))
) {
GeneralUtility::writeFile(Environment::getPublicPath() . '/' . $filename, $externalContent);
}
return $filename;
}
public function compressJavaScriptSource(string $javaScriptSourceCode): string
{
$this->initialize();
$fakeThis = null;
foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_div.php']['minifyJavaScript'] ?? [] as $hookMethod) {
try {
$parameters = ['script' => $javaScriptSourceCode];
$javaScriptSourceCode = GeneralUtility::callUserFunction($hookMethod, $parameters, $fakeThis);
} catch (\Exception $e) {
GeneralUtility::makeInstance(LogManager::class)->getLogger(__CLASS__)->warning('Error minifying Javascript: {file}, hook: {hook}', [
'file' => $javaScriptSourceCode,
'hook' => $hookMethod,
'exception' => $e,
]);
}
}
return $javaScriptSourceCode;
}
/**
* Compress a CSS string by removing comments and whitespace characters
*
* @param string $contents
* @return string
*/
protected function compressCssString($contents)
{
// Perform some safe CSS optimizations.
// Regexp to match comment blocks.
$comment = '/\*[^*]*\*+(?:[^/*][^*]*\*+)*/';
// Regexp to match double quoted strings.
$double_quot = '"[^"\\\\]*(?:\\\\.[^"\\\\]*)*"';
// Regexp to match single quoted strings.
$single_quot = "'[^'\\\\]*(?:\\\\.[^'\\\\]*)*'";
// Strip all comment blocks, but keep double/single quoted strings.
$contents = (string)preg_replace(
"<($double_quot|$single_quot)|$comment>Ss",
'$1',
$contents
);
// Remove certain whitespace.
// There are different conditions for removing leading and trailing
// whitespace.
// @see https://php.net/manual/regexp.reference.subpatterns.php
$contents = (string)preg_replace(
'<
# Strip leading and trailing whitespace.
\s*([@{};,])\s*
# Strip only leading whitespace from:
# - Closing parenthesis: Retain "@media (bar) and foo".
| \s+([\)])
# Strip only trailing whitespace from:
# - Opening parenthesis: Retain "@media (bar) and foo".
# - Colon: Retain :pseudo-selectors.
| ([\(:])\s+
>xS',
// Only one of the three capturing groups will match, so its reference
// will contain the wanted value and the references for the
// two non-matching groups will be replaced with empty strings.
'$1$2$3',
$contents
);
// End the file with a new line.
$contents = trim($contents);
// Ensure file ends in newline.
$contents .= LF;
return $contents;
}
/**
* Determines the JavaScript mime type
*
* The <script> tag only needs the type if the page is not rendered as HTML5.
* For TYPO3 Frontend the configured config.doctype is evaluated.
*/
protected function getJavaScriptFileType(): string
{
if (!isset($GLOBALS['TSFE'])
|| !($GLOBALS['TSFE'] instanceof TypoScriptFrontendController)
|| ($GLOBALS['TSFE']->config['config']['doctype'] ?? 'html5') === 'html5'
) {
// no TSFE, or doctype set to html5
return '';
}
return 'text/javascript';
}
protected function getPathFixer(): RelativeCssPathFixer
{
return GeneralUtility::makeInstance(RelativeCssPathFixer::class);
}
}