TYPO3CMS  8
 All Classes Namespaces Files Functions Variables Pages
ResourceCompressor.php
Go to the documentation of this file.
1 <?php
2 namespace TYPO3\CMS\Core\Resource;
3 
4 /*
5  * This file is part of the TYPO3 CMS project.
6  *
7  * It is free software; you can redistribute it and/or modify it under
8  * the terms of the GNU General Public License, either version 2
9  * of the License, or any later version.
10  *
11  * For the full copyright and license information, please read the
12  * LICENSE.txt file that was distributed with this source code.
13  *
14  * The TYPO3 project - inspiring people to share!
15  */
16 
20 
26 {
30  protected $targetDirectory = 'typo3temp/assets/compressed/';
31 
35  protected $rootPath = '';
36 
42  protected $createGzipped = false;
43 
47  protected $gzipCompressionLevel = -1;
48 
49  protected $htaccessTemplate = '<FilesMatch "\\.(js|css)(\\.gzip)?$">
50  <IfModule mod_expires.c>
51  ExpiresActive on
52  ExpiresDefault "access plus 7 days"
53  </IfModule>
54  FileETag MTime Size
55 </FilesMatch>';
56 
60  public function __construct()
61  {
62  // we check for existence of our targetDirectory
63  if (!is_dir(PATH_site . $this->targetDirectory)) {
64  GeneralUtility::mkdir_deep(PATH_site . $this->targetDirectory);
65  }
66  // if enabled, we check whether we should auto-create the .htaccess file
67  if ($GLOBALS['TYPO3_CONF_VARS']['SYS']['generateApacheHtaccess']) {
68  // check whether .htaccess exists
69  $htaccessPath = PATH_site . $this->targetDirectory . '.htaccess';
70  if (!file_exists($htaccessPath)) {
71  GeneralUtility::writeFile($htaccessPath, $this->htaccessTemplate);
72  }
73  }
74  // decide whether we should create gzipped versions or not
75  $compressionLevel = $GLOBALS['TYPO3_CONF_VARS'][TYPO3_MODE]['compressionLevel'];
76  // we need zlib for gzencode()
77  if (extension_loaded('zlib') && $compressionLevel) {
78  $this->createGzipped = true;
79  // $compressionLevel can also be TRUE
80  if (MathUtility::canBeInterpretedAsInteger($compressionLevel)) {
81  $this->gzipCompressionLevel = (int)$compressionLevel;
82  }
83  }
84  $this->setRootPath(TYPO3_MODE === 'BE' ? PATH_typo3 : PATH_site);
85  }
86 
93  public function setRootPath($rootPath)
94  {
95  if (is_string($rootPath)) {
96  $this->rootPath = $rootPath;
97  }
98  }
99 
110  public function concatenateCssFiles(array $cssFiles, array $options = [])
111  {
112  $filesToIncludeByType = ['all' => []];
113  foreach ($cssFiles as $key => $fileOptions) {
114  // no concatenation allowed for this file, so continue
115  if (!empty($fileOptions['excludeFromConcatenation'])) {
116  continue;
117  }
118  $filenameFromMainDir = $this->getFilenameFromMainDir($fileOptions['file']);
119  // if $options['baseDirectories'] set, we only include files below these directories
120  if (
121  !isset($options['baseDirectories'])
122  || $this->checkBaseDirectory(
123  $filenameFromMainDir, array_merge($options['baseDirectories'], [$this->targetDirectory])
124  )
125  ) {
126  $type = isset($fileOptions['media']) ? strtolower($fileOptions['media']) : 'all';
127  if (!isset($filesToIncludeByType[$type])) {
128  $filesToIncludeByType[$type] = [];
129  }
130  if ($fileOptions['forceOnTop']) {
131  array_unshift($filesToIncludeByType[$type], $filenameFromMainDir);
132  } else {
133  $filesToIncludeByType[$type][] = $filenameFromMainDir;
134  }
135  // remove the file from the incoming file array
136  unset($cssFiles[$key]);
137  }
138  }
139  if (!empty($filesToIncludeByType)) {
140  foreach ($filesToIncludeByType as $mediaOption => $filesToInclude) {
141  if (empty($filesToInclude)) {
142  continue;
143  }
144  $targetFile = $this->createMergedCssFile($filesToInclude);
145  $concatenatedOptions = [
146  'file' => $targetFile,
147  'rel' => 'stylesheet',
148  'media' => $mediaOption,
149  'compress' => true,
150  'excludeFromConcatenation' => true,
151  'forceOnTop' => false,
152  'allWrap' => ''
153  ];
154  // place the merged stylesheet on top of the stylesheets
155  $cssFiles = array_merge($cssFiles, [$targetFile => $concatenatedOptions]);
156  }
157  }
158  return $cssFiles;
159  }
160 
167  public function concatenateJsFiles(array $jsFiles)
168  {
169  $filesToInclude = [];
170  foreach ($jsFiles as $key => $fileOptions) {
171  // invalid section found or no concatenation allowed, so continue
172  if (empty($fileOptions['section']) || !empty($fileOptions['excludeFromConcatenation'])) {
173  continue;
174  }
175  if (!isset($filesToInclude[$fileOptions['section']])) {
176  $filesToInclude[$fileOptions['section']] = [];
177  }
178  $filenameFromMainDir = $this->getFilenameFromMainDir($fileOptions['file']);
179  if ($fileOptions['forceOnTop']) {
180  array_unshift($filesToInclude[$fileOptions['section']], $filenameFromMainDir);
181  } else {
182  $filesToInclude[$fileOptions['section']][] = $filenameFromMainDir;
183  }
184  // remove the file from the incoming file array
185  unset($jsFiles[$key]);
186  }
187  if (!empty($filesToInclude)) {
188  foreach ($filesToInclude as $section => $files) {
189  $targetFile = $this->createMergedJsFile($files);
190  $concatenatedOptions = [
191  'file' => $targetFile,
192  'type' => 'text/javascript',
193  'section' => $section,
194  'compress' => true,
195  'excludeFromConcatenation' => true,
196  'forceOnTop' => false,
197  'allWrap' => ''
198  ];
199  // place the merged javascript on top of the JS files
200  $jsFiles = array_merge([$targetFile => $concatenatedOptions], $jsFiles);
201  }
202  }
203  return $jsFiles;
204  }
205 
212  protected function createMergedCssFile(array $filesToInclude)
213  {
214  return $this->createMergedFile($filesToInclude, 'css');
215  }
216 
223  protected function createMergedJsFile(array $filesToInclude)
224  {
225  return $this->createMergedFile($filesToInclude, 'js');
226  }
227 
237  protected function createMergedFile(array $filesToInclude, $type = 'css')
238  {
239  // Get file type
240  $type = strtolower(trim($type, '. '));
241  if (empty($type)) {
242  throw new \InvalidArgumentException('No valid file type given for files to be merged.', 1308957498);
243  }
244  // we add up the filenames, filemtimes and filsizes to later build a checksum over
245  // it and include it in the temporary file name
246  $unique = '';
247  foreach ($filesToInclude as $key => $filename) {
248  if (GeneralUtility::isValidUrl($filename)) {
249  // check if it is possibly a local file with fully qualified URL
250  if (GeneralUtility::isOnCurrentHost($filename) &&
252  $filename,
253  GeneralUtility::getIndpEnv('TYPO3_SITE_URL')
254  )
255  ) {
256  // attempt to turn it into a local file path
257  $localFilename = substr($filename, strlen(GeneralUtility::getIndpEnv('TYPO3_SITE_URL')));
258  if (@is_file(GeneralUtility::resolveBackPath($this->rootPath . $localFilename))) {
259  $filesToInclude[$key] = $localFilename;
260  } else {
261  $filesToInclude[$key] = $this->retrieveExternalFile($filename);
262  }
263  } else {
264  $filesToInclude[$key] = $this->retrieveExternalFile($filename);
265  }
266  $filename = $filesToInclude[$key];
267  }
268  $filenameAbsolute = GeneralUtility::resolveBackPath($this->rootPath . $filename);
269  if (@file_exists($filenameAbsolute)) {
270  $fileStatus = stat($filenameAbsolute);
271  $unique .= $filenameAbsolute . $fileStatus['mtime'] . $fileStatus['size'];
272  } else {
273  $unique .= $filenameAbsolute;
274  }
275  }
276  $targetFile = $this->targetDirectory . 'merged-' . md5($unique) . '.' . $type;
277  // if the file doesn't already exist, we create it
278  if (!file_exists(PATH_site . $targetFile)) {
279  $concatenated = '';
280  // concatenate all the files together
281  foreach ($filesToInclude as $filename) {
282  $filenameAbsolute = GeneralUtility::resolveBackPath($this->rootPath . $filename);
283  $filename = PathUtility::stripPathSitePrefix($filenameAbsolute);
284  $contents = file_get_contents($filenameAbsolute);
285  // remove any UTF-8 byte order mark (BOM) from files
286  if (strpos($contents, "\xEF\xBB\xBF") === 0) {
287  $contents = substr($contents, 3);
288  }
289  // only fix paths if files aren't already in typo3temp (already processed)
290  if ($type === 'css' && !GeneralUtility::isFirstPartOfStr($filename, $this->targetDirectory)) {
291  $contents = $this->cssFixRelativeUrlPaths($contents, PathUtility::dirname($filename) . '/');
292  }
293  $concatenated .= LF . $contents;
294  }
295  // move @charset, @import and @namespace statements to top of new file
296  if ($type === 'css') {
297  $concatenated = $this->cssFixStatements($concatenated);
298  }
299  GeneralUtility::writeFile(PATH_site . $targetFile, $concatenated);
300  }
301  return $targetFile;
302  }
303 
310  public function compressCssFiles(array $cssFiles)
311  {
312  $filesAfterCompression = [];
313  foreach ($cssFiles as $key => $fileOptions) {
314  // if compression is enabled
315  if ($fileOptions['compress']) {
316  $filename = $this->compressCssFile($fileOptions['file']);
317  $fileOptions['compress'] = false;
318  $fileOptions['file'] = $filename;
319  $filesAfterCompression[$filename] = $fileOptions;
320  } else {
321  $filesAfterCompression[$key] = $fileOptions;
322  }
323  }
324  return $filesAfterCompression;
325  }
326 
339  public function compressCssFile($filename)
340  {
341  // generate the unique name of the file
342  $filenameAbsolute = GeneralUtility::resolveBackPath($this->rootPath . $this->getFilenameFromMainDir($filename));
343  if (@file_exists($filenameAbsolute)) {
344  $fileStatus = stat($filenameAbsolute);
345  $unique = $filenameAbsolute . $fileStatus['mtime'] . $fileStatus['size'];
346  } else {
347  $unique = $filenameAbsolute;
348  }
349  // make sure it is again the full filename
350  $filename = PathUtility::stripPathSitePrefix($filenameAbsolute);
351 
352  $pathinfo = PathUtility::pathinfo($filenameAbsolute);
353  $targetFile = $this->targetDirectory . $pathinfo['filename'] . '-' . md5($unique) . '.css';
354  // only create it, if it doesn't exist, yet
355  if (!file_exists(PATH_site . $targetFile) || $this->createGzipped && !file_exists(PATH_site . $targetFile . '.gzip')) {
356  $contents = $this->compressCssString(file_get_contents($filenameAbsolute));
357  if (strpos($filename, $this->targetDirectory) === false) {
358  $contents = $this->cssFixRelativeUrlPaths($contents, PathUtility::dirname($filename) . '/');
359  }
360  $this->writeFileAndCompressed($targetFile, $contents);
361  }
362  return $this->returnFileReference($targetFile);
363  }
364 
371  public function compressJsFiles(array $jsFiles)
372  {
373  $filesAfterCompression = [];
374  foreach ($jsFiles as $fileName => $fileOptions) {
375  // If compression is enabled
376  if ($fileOptions['compress']) {
377  $compressedFilename = $this->compressJsFile($fileOptions['file']);
378  $fileOptions['compress'] = false;
379  $fileOptions['file'] = $compressedFilename;
380  $filesAfterCompression[$compressedFilename] = $fileOptions;
381  } else {
382  $filesAfterCompression[$fileName] = $fileOptions;
383  }
384  }
385  return $filesAfterCompression;
386  }
387 
394  public function compressJsFile($filename)
395  {
396  // generate the unique name of the file
397  $filenameAbsolute = GeneralUtility::resolveBackPath($this->rootPath . $this->getFilenameFromMainDir($filename));
398  if (@file_exists($filenameAbsolute)) {
399  $fileStatus = stat($filenameAbsolute);
400  $unique = $filenameAbsolute . $fileStatus['mtime'] . $fileStatus['size'];
401  } else {
402  $unique = $filenameAbsolute;
403  }
404  $pathinfo = PathUtility::pathinfo($filename);
405  $targetFile = $this->targetDirectory . $pathinfo['filename'] . '-' . md5($unique) . '.js';
406  // only create it, if it doesn't exist, yet
407  if (!file_exists(PATH_site . $targetFile) || $this->createGzipped && !file_exists(PATH_site . $targetFile . '.gzip')) {
408  $contents = file_get_contents($filenameAbsolute);
409  $this->writeFileAndCompressed($targetFile, $contents);
410  }
411  return $this->returnFileReference($targetFile);
412  }
413 
420  protected function getFilenameFromMainDir($filename)
421  {
422  /*
423  * The various paths may have those values (e.g. if TYPO3 is installed in a subdir)
424  * - docRoot = /var/www/html/
425  * - PATH_site = /var/www/html/sites/site1/
426  * - $this->rootPath = /var/www/html/sites/site1/typo3
427  *
428  * The file names passed into this function may be either:
429  * - relative to $this->rootPath
430  * - relative to PATH_site
431  * - relative to docRoot
432  */
433  $docRoot = GeneralUtility::getIndpEnv('TYPO3_DOCUMENT_ROOT');
434  $fileNameWithoutSlash = ltrim($filename, '/');
435 
436  // if the file is an absolute reference within the docRoot
437  $absolutePath = $docRoot . '/' . $fileNameWithoutSlash;
438  if (is_file($absolutePath)) {
439  if (strpos($absolutePath, $this->rootPath) === 0) {
440  // the path is within the current root path, simply strip rootPath off
441  return substr($absolutePath, strlen($this->rootPath));
442  }
443  // the path is not within the root path, strip off the site path, the remaining logic below
444  // takes care about adjusting the path correctly.
445  $filename = substr($absolutePath, strlen(PATH_site));
446  }
447  // if the file exists in the root path, just return the $filename
448  if (is_file($this->rootPath . $fileNameWithoutSlash)) {
449  return $fileNameWithoutSlash;
450  }
451  // if the file is from a special TYPO3 internal directory, add the missing typo3/ prefix
452  if (is_file(realpath(PATH_site . TYPO3_mainDir . $filename))) {
453  $filename = TYPO3_mainDir . $filename;
454  }
455  // build the file path relatively to the PATH_site
456  if (strpos($filename, 'EXT:') === 0) {
457  $file = GeneralUtility::getFileAbsFileName($filename);
458  } elseif (strpos($filename, '../') === 0) {
459  $file = GeneralUtility::resolveBackPath(PATH_typo3 . $filename);
460  } else {
461  $file = PATH_site . $fileNameWithoutSlash;
462  }
463 
464  // check if the file exists, and if so, return the path relative to TYPO3_mainDir
465  if (is_file($file)) {
466  $mainDirDepth = substr_count(TYPO3_mainDir, '/');
467  return str_repeat('../', $mainDirDepth) . str_replace(PATH_site, '', $file);
468  }
469  // none of above conditions were met, fallback to default behaviour
470  return $filename;
471  }
472 
480  protected function checkBaseDirectory($filename, array $baseDirectories)
481  {
482  foreach ($baseDirectories as $baseDirectory) {
483  // check, if $filename starts with base directory
484  if (GeneralUtility::isFirstPartOfStr($filename, $baseDirectory)) {
485  return true;
486  }
487  }
488  return false;
489  }
490 
498  protected function cssFixRelativeUrlPaths($contents, $oldDir)
499  {
500  $newDir = '../../../' . $oldDir;
501  // Replace "url()" paths
502  if (stripos($contents, 'url') !== false) {
503  $regex = '/url(\\(\\s*["\']?(?!\\/)([^"\']+)["\']?\\s*\\))/iU';
504  $contents = $this->findAndReplaceUrlPathsByRegex($contents, $regex, $newDir, '(\'|\')');
505  }
506  // Replace "@import" paths
507  if (stripos($contents, '@import') !== false) {
508  $regex = '/@import\\s*(["\']?(?!\\/)([^"\']+)["\']?)/i';
509  $contents = $this->findAndReplaceUrlPathsByRegex($contents, $regex, $newDir, '"|"');
510  }
511  return $contents;
512  }
513 
523  protected function findAndReplaceUrlPathsByRegex($contents, $regex, $newDir, $wrap = '|')
524  {
525  $matches = [];
526  $replacements = [];
527  $wrap = explode('|', $wrap);
528  preg_match_all($regex, $contents, $matches);
529  foreach ($matches[2] as $matchCount => $match) {
530  // remove '," or white-spaces around
531  $match = trim($match, '\'" ');
532  // we must not rewrite paths containing ":" or "url(", e.g. data URIs (see RFC 2397)
533  if (strpos($match, ':') === false && !preg_match('/url\\s*\\(/i', $match)) {
534  $newPath = GeneralUtility::resolveBackPath($newDir . $match);
535  $replacements[$matches[1][$matchCount]] = $wrap[0] . $newPath . $wrap[1];
536  }
537  }
538  // replace URL paths in content
539  if (!empty($replacements)) {
540  $contents = str_replace(array_keys($replacements), array_values($replacements), $contents);
541  }
542  return $contents;
543  }
544 
552  protected function cssFixStatements($contents)
553  {
554  $matches = [];
555  $comment = LF . '/* moved by compressor */' . LF;
556  // nothing to do, so just return contents
557  if (stripos($contents, '@charset') === false && stripos($contents, '@import') === false && stripos($contents, '@namespace') === false) {
558  return $contents;
559  }
560  $regex = '/@(charset|import|namespace)\\s*(url)?\\s*\\(?\\s*["\']?[^"\'\\)]+["\']?\\s*\\)?\\s*;/i';
561  preg_match_all($regex, $contents, $matches);
562  if (!empty($matches[0])) {
563  // Ensure correct order of @charset, @namespace and @import
564  $charset = '';
565  $namespaces = [];
566  $imports = [];
567  foreach ($matches[1] as $index => $keyword) {
568  switch ($keyword) {
569  case 'charset':
570  if (empty($charset)) {
571  $charset = $matches[0][$index];
572  }
573  break;
574  case 'namespace':
575  $namespaces[] = $matches[0][$index];
576  break;
577  case 'import':
578  $imports[] = $matches[0][$index];
579  break;
580  }
581  }
582 
583  $namespaces = !empty($namespaces) ? implode('', $namespaces) . $comment : '';
584  $imports = !empty($imports) ? implode('', $imports) . $comment : '';
585  // remove existing statements
586  $contents = str_replace($matches[0], '', $contents);
587  // add statements to the top of contents in the order they occur in original file
588  $contents =
589  $charset
590  . $comment
591  . $namespaces
592  . $imports
593  . trim($contents);
594  }
595  return $contents;
596  }
597 
605  protected function writeFileAndCompressed($filename, $contents)
606  {
607  // write uncompressed file
608  GeneralUtility::writeFile(PATH_site . $filename, $contents);
609  if ($this->createGzipped) {
610  // create compressed version
611  GeneralUtility::writeFile(PATH_site . $filename . '.gzip', gzencode($contents, $this->gzipCompressionLevel));
612  }
613  }
614 
622  protected function returnFileReference($filename)
623  {
624  // if the client accepts gzip and we can create gzipped files, we give him compressed versions
625  if ($this->createGzipped && strpos(GeneralUtility::getIndpEnv('HTTP_ACCEPT_ENCODING'), 'gzip') !== false) {
626  $filename .= '.gzip';
627  }
628  return PathUtility::getRelativePath($this->rootPath, PATH_site) . $filename;
629  }
630 
637  protected function retrieveExternalFile($url)
638  {
639  $externalContent = GeneralUtility::getUrl($url);
640  $filename = $this->targetDirectory . 'external-' . md5($url);
641  // write only if file does not exist and md5 of the content is not the same as fetched one
642  if (!file_exists(PATH_site . $filename)
643  && (md5($externalContent) !== md5(file_get_contents(PATH_site . $filename)))
644  ) {
645  GeneralUtility::writeFile(PATH_site . $filename, $externalContent);
646  }
647  return $filename;
648  }
649 
656  protected function compressCssString($contents)
657  {
658  // Perform some safe CSS optimizations.
659  // Regexp to match comment blocks.
660  $comment = '/\*[^*]*\*+(?:[^/*][^*]*\*+)*/';
661  // Regexp to match double quoted strings.
662  $double_quot = '"[^"\\\\]*(?:\\\\.[^"\\\\]*)*"';
663  // Regexp to match single quoted strings.
664  $single_quot = "'[^'\\\\]*(?:\\\\.[^'\\\\]*)*'";
665  // Strip all comment blocks, but keep double/single quoted strings.
666  $contents = preg_replace(
667  "<($double_quot|$single_quot)|$comment>Ss",
668  '$1',
669  $contents
670  );
671  // Remove certain whitespace.
672  // There are different conditions for removing leading and trailing
673  // whitespace.
674  // @see http://php.net/manual/regexp.reference.subpatterns.php
675  $contents = preg_replace('<
676  # Strip leading and trailing whitespace.
677  \s*([@{};,])\s*
678  # Strip only leading whitespace from:
679  # - Closing parenthesis: Retain "@media (bar) and foo".
680  | \s+([\)])
681  # Strip only trailing whitespace from:
682  # - Opening parenthesis: Retain "@media (bar) and foo".
683  # - Colon: Retain :pseudo-selectors.
684  | ([\(:])\s+
685  >xS',
686  // Only one of the three capturing groups will match, so its reference
687  // will contain the wanted value and the references for the
688  // two non-matching groups will be replaced with empty strings.
689  '$1$2$3',
690  $contents
691  );
692  // End the file with a new line.
693  $contents = trim($contents);
694  // Ensure file ends in newline.
695  $contents .= LF;
696  return $contents;
697  }
698 }
checkBaseDirectory($filename, array $baseDirectories)
static isFirstPartOfStr($str, $partStr)
findAndReplaceUrlPathsByRegex($contents, $regex, $newDir, $wrap= '|')
createMergedFile(array $filesToInclude, $type= 'css')
static mkdir_deep($directory, $deepDirectory= '')
static writeFile($file, $content, $changePermissions=false)
concatenateCssFiles(array $cssFiles, array $options=[])
if(TYPO3_MODE=== 'BE') $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tsfebeuserauth.php']['frontendEditingController']['default']
static pathinfo($path, $options=null)
static getRelativePath($sourcePath, $targetPath)
Definition: PathUtility.php:70
static getFileAbsFileName($filename, $_=null, $_2=null)