TYPO3  7.6
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/compressor/';
31 
35  protected $relativePath = '';
36 
40  protected $rootPath = '';
41 
45  protected $backPath = '';
46 
52  protected $createGzipped = false;
53 
57  protected $gzipCompressionLevel = -1;
58 
59  protected $htaccessTemplate = '<FilesMatch "\\.(js|css)(\\.gzip)?$">
60  <IfModule mod_expires.c>
61  ExpiresActive on
62  ExpiresDefault "access plus 7 days"
63  </IfModule>
64  FileETag MTime Size
65 </FilesMatch>';
66 
70  public function __construct()
71  {
72  // we check for existence of our targetDirectory
73  if (!is_dir(PATH_site . $this->targetDirectory)) {
74  GeneralUtility::mkdir(PATH_site . $this->targetDirectory);
75  }
76  // if enabled, we check whether we should auto-create the .htaccess file
77  if ($GLOBALS['TYPO3_CONF_VARS']['SYS']['generateApacheHtaccess']) {
78  // check whether .htaccess exists
79  $htaccessPath = PATH_site . $this->targetDirectory . '.htaccess';
80  if (!file_exists($htaccessPath)) {
81  GeneralUtility::writeFile($htaccessPath, $this->htaccessTemplate);
82  }
83  }
84  // decide whether we should create gzipped versions or not
85  $compressionLevel = $GLOBALS['TYPO3_CONF_VARS'][TYPO3_MODE]['compressionLevel'];
86  // we need zlib for gzencode()
87  if (extension_loaded('zlib') && $compressionLevel) {
88  $this->createGzipped = true;
89  // $compressionLevel can also be TRUE
90  if (MathUtility::canBeInterpretedAsInteger($compressionLevel)) {
91  $this->gzipCompressionLevel = (int)$compressionLevel;
92  }
93  }
94  $this->setInitialPaths();
95  }
96 
102  public function setInitialPaths()
103  {
104  $this->setInitialRelativePath();
105  $this->setInitialRootPath();
106  $this->setInitialBackPath();
107  }
108 
114  protected function setInitialBackPath()
115  {
116  $backPath = TYPO3_MODE === 'BE' ? $GLOBALS['BACK_PATH'] : '';
117  $this->setBackPath($backPath);
118  }
119 
125  protected function setInitialRootPath()
126  {
127  $rootPath = TYPO3_MODE === 'BE' ? PATH_typo3 : PATH_site;
128  $this->setRootPath($rootPath);
129  }
130 
136  protected function setInitialRelativePath()
137  {
138  $relativePath = TYPO3_MODE === 'BE' ? $GLOBALS['BACK_PATH'] . '../' : '';
140  }
141 
149  {
150  if (is_string($relativePath)) {
151  $this->relativePath = $relativePath;
152  }
153  }
154 
161  public function setRootPath($rootPath)
162  {
163  if (is_string($rootPath)) {
164  $this->rootPath = $rootPath;
165  }
166  }
167 
174  public function setBackPath($backPath)
175  {
176  if (is_string($backPath)) {
177  $this->backPath = $backPath;
178  }
179  }
180 
191  public function concatenateCssFiles(array $cssFiles, array $options = array())
192  {
193  $filesToIncludeByType = array('all' => array());
194  foreach ($cssFiles as $key => $fileOptions) {
195  // no concatenation allowed for this file, so continue
196  if (!empty($fileOptions['excludeFromConcatenation'])) {
197  continue;
198  }
199  // we remove BACK_PATH from $filename, so make it relative to root path
200  $filenameFromMainDir = $this->getFilenameFromMainDir($fileOptions['file']);
201  // if $options['baseDirectories'] set, we only include files below these directories
202  if (
203  !isset($options['baseDirectories'])
204  || $this->checkBaseDirectory(
205  $filenameFromMainDir, array_merge($options['baseDirectories'], array($this->targetDirectory))
206  )
207  ) {
208  $type = isset($fileOptions['media']) ? strtolower($fileOptions['media']) : 'all';
209  if (!isset($filesToIncludeByType[$type])) {
210  $filesToIncludeByType[$type] = array();
211  }
212  if ($fileOptions['forceOnTop']) {
213  array_unshift($filesToIncludeByType[$type], $filenameFromMainDir);
214  } else {
215  $filesToIncludeByType[$type][] = $filenameFromMainDir;
216  }
217  // remove the file from the incoming file array
218  unset($cssFiles[$key]);
219  }
220  }
221  if (!empty($filesToIncludeByType)) {
222  foreach ($filesToIncludeByType as $mediaOption => $filesToInclude) {
223  if (empty($filesToInclude)) {
224  continue;
225  }
226  $targetFile = $this->createMergedCssFile($filesToInclude);
227  $targetFileRelative = $this->relativePath . $targetFile;
228  $concatenatedOptions = array(
229  'file' => $targetFileRelative,
230  'rel' => 'stylesheet',
231  'media' => $mediaOption,
232  'compress' => true,
233  'excludeFromConcatenation' => true,
234  'forceOnTop' => false,
235  'allWrap' => ''
236  );
237  // place the merged stylesheet on top of the stylesheets
238  $cssFiles = array_merge($cssFiles, array($targetFileRelative => $concatenatedOptions));
239  }
240  }
241  return $cssFiles;
242  }
243 
250  public function concatenateJsFiles(array $jsFiles)
251  {
252  $filesToInclude = array();
253  foreach ($jsFiles as $key => $fileOptions) {
254  // invalid section found or no concatenation allowed, so continue
255  if (empty($fileOptions['section']) || !empty($fileOptions['excludeFromConcatenation'])) {
256  continue;
257  }
258  if (!isset($filesToInclude[$fileOptions['section']])) {
259  $filesToInclude[$fileOptions['section']] = array();
260  }
261  // we remove BACK_PATH from $filename, so make it relative to root path
262  $filenameFromMainDir = $this->getFilenameFromMainDir($fileOptions['file']);
263  if ($fileOptions['forceOnTop']) {
264  array_unshift($filesToInclude[$fileOptions['section']], $filenameFromMainDir);
265  } else {
266  $filesToInclude[$fileOptions['section']][] = $filenameFromMainDir;
267  }
268  // remove the file from the incoming file array
269  unset($jsFiles[$key]);
270  }
271  if (!empty($filesToInclude)) {
272  foreach ($filesToInclude as $section => $files) {
273  $targetFile = $this->createMergedJsFile($files);
274  $targetFileRelative = $this->relativePath . $targetFile;
275  $concatenatedOptions = array(
276  'file' => $targetFileRelative,
277  'type' => 'text/javascript',
278  'section' => $section,
279  'compress' => true,
280  'excludeFromConcatenation' => true,
281  'forceOnTop' => false,
282  'allWrap' => ''
283  );
284  // place the merged javascript on top of the JS files
285  $jsFiles = array_merge(array($targetFileRelative => $concatenatedOptions), $jsFiles);
286  }
287  }
288  return $jsFiles;
289  }
290 
297  protected function createMergedCssFile(array $filesToInclude)
298  {
299  return $this->createMergedFile($filesToInclude, 'css');
300  }
301 
308  protected function createMergedJsFile(array $filesToInclude)
309  {
310  return $this->createMergedFile($filesToInclude, 'js');
311  }
312 
322  protected function createMergedFile(array $filesToInclude, $type = 'css')
323  {
324  // Get file type
325  $type = strtolower(trim($type, '. '));
326  if (empty($type)) {
327  throw new \InvalidArgumentException('No valid file type given for files to be merged.', 1308957498);
328  }
329  // we add up the filenames, filemtimes and filsizes to later build a checksum over
330  // it and include it in the temporary file name
331  $unique = '';
332  foreach ($filesToInclude as $key => $filename) {
334  // check if it is possibly a local file with fully qualified URL
337  $filename,
338  GeneralUtility::getIndpEnv('TYPO3_SITE_URL')
339  )
340  ) {
341  // attempt to turn it into a local file path
342  $localFilename = substr($filename, strlen(GeneralUtility::getIndpEnv('TYPO3_SITE_URL')));
343  if (@is_file(GeneralUtility::resolveBackPath($this->rootPath . $localFilename))) {
344  $filesToInclude[$key] = $localFilename;
345  } else {
346  $filesToInclude[$key] = $this->retrieveExternalFile($filename);
347  }
348  } else {
349  $filesToInclude[$key] = $this->retrieveExternalFile($filename);
350  }
351  $filename = $filesToInclude[$key];
352  }
353  $filenameAbsolute = GeneralUtility::resolveBackPath($this->rootPath . $filename);
354  if (@file_exists($filenameAbsolute)) {
355  $fileStatus = stat($filenameAbsolute);
356  $unique .= $filenameAbsolute . $fileStatus['mtime'] . $fileStatus['size'];
357  } else {
358  $unique .= $filenameAbsolute;
359  }
360  }
361  $targetFile = $this->targetDirectory . 'merged-' . md5($unique) . '.' . $type;
362  // if the file doesn't already exist, we create it
363  if (!file_exists((PATH_site . $targetFile))) {
364  $concatenated = '';
365  // concatenate all the files together
366  foreach ($filesToInclude as $filename) {
367  $contents = GeneralUtility::getUrl(GeneralUtility::resolveBackPath($this->rootPath . $filename));
368  // only fix paths if files aren't already in typo3temp (already processed)
369  if ($type === 'css' && !GeneralUtility::isFirstPartOfStr($filename, $this->targetDirectory)) {
370  $contents = $this->cssFixRelativeUrlPaths($contents, PathUtility::dirname($filename) . '/');
371  }
372  $concatenated .= LF . $contents;
373  }
374  // move @charset, @import and @namespace statements to top of new file
375  if ($type === 'css') {
376  $concatenated = $this->cssFixStatements($concatenated);
377  }
378  GeneralUtility::writeFile(PATH_site . $targetFile, $concatenated);
379  }
380  return $targetFile;
381  }
382 
389  public function compressCssFiles(array $cssFiles)
390  {
391  $filesAfterCompression = array();
392  foreach ($cssFiles as $key => $fileOptions) {
393  // if compression is enabled
394  if ($fileOptions['compress']) {
395  $filename = $this->compressCssFile($fileOptions['file']);
396  $fileOptions['compress'] = false;
397  $fileOptions['file'] = $filename;
398  $filesAfterCompression[$filename] = $fileOptions;
399  } else {
400  $filesAfterCompression[$key] = $fileOptions;
401  }
402  }
403  return $filesAfterCompression;
404  }
405 
418  public function compressCssFile($filename)
419  {
420  // generate the unique name of the file
421  $filenameAbsolute = GeneralUtility::resolveBackPath($this->rootPath . $this->getFilenameFromMainDir($filename));
422  if (@file_exists($filenameAbsolute)) {
423  $fileStatus = stat($filenameAbsolute);
424  $unique = $filenameAbsolute . $fileStatus['mtime'] . $fileStatus['size'];
425  } else {
426  $unique = $filenameAbsolute;
427  }
428 
429  $pathinfo = PathUtility::pathinfo($filename);
430  $targetFile = $this->targetDirectory . $pathinfo['filename'] . '-' . md5($unique) . '.css';
431  // only create it, if it doesn't exist, yet
432  if (!file_exists((PATH_site . $targetFile)) || $this->createGzipped && !file_exists((PATH_site . $targetFile . '.gzip'))) {
433  $contents = $this->compressCssString(GeneralUtility::getUrl($filenameAbsolute));
434  if (strpos($filename, $this->targetDirectory) === false) {
435  $contents = $this->cssFixRelativeUrlPaths($contents, PathUtility::dirname($filename) . '/');
436  }
437  $this->writeFileAndCompressed($targetFile, $contents);
438  }
439  return $this->relativePath . $this->returnFileReference($targetFile);
440  }
441 
450  public static function compressCssPregCallback($matches)
451  {
453  if ($matches[1]) {
454  // Group 1: Double quoted string.
455  return $matches[1];
456  } elseif ($matches[2]) {
457  // Group 2: Single quoted string.
458  return $matches[2];
459  } elseif ($matches[3]) {
460  // Group 3: Regular non-MacIE5-hack comment.
461  return '
462 ';
463  } elseif ($matches[4]) {
464  // Group 4: MacIE5-hack-type-1 comment.
465  return '
466 /*\\T1*/
467 ';
468  } elseif ($matches[5]) {
469  // Group 5,6,7: MacIE5-hack-type-2 comment
470  $matches[6] = preg_replace('/\\s++([+>{};,)])/S', '$1', $matches[6]);
471  // Clean pre-punctuation.
472  $matches[6] = preg_replace('/([+>{}:;,(])\\s++/S', '$1', $matches[6]);
473  // Clean post-punctuation.
474  $matches[6] = preg_replace('/;?\\}/S', '}
475 ', $matches[6]);
476  // Add a touch of formatting.
477  return '
478 /*T2\\*/' . $matches[6] . '
479 /*T2E*/
480 ';
481  } elseif ($matches[8]) {
482  // Group 8: calc function (see http://www.w3.org/TR/2006/WD-css3-values-20060919/#calc)
483  return 'calc' . $matches[8];
484  } elseif (isset($matches[9])) {
485  // Group 9: Non-string, non-comment. Safe to clean whitespace here.
486  $matches[9] = preg_replace('/^\\s++/', '', $matches[9]);
487  // Strip all leading whitespace.
488  $matches[9] = preg_replace('/\\s++$/', '', $matches[9]);
489  // Strip all trailing whitespace.
490  $matches[9] = preg_replace('/\\s{2,}+/', ' ', $matches[9]);
491  // Consolidate multiple whitespace.
492  $matches[9] = preg_replace('/\\s++([+>{};,)])/S', '$1', $matches[9]);
493  // Clean pre-punctuation.
494  $matches[9] = preg_replace('/([+>{}:;,(])\\s++/S', '$1', $matches[9]);
495  // Clean post-punctuation.
496  $matches[9] = preg_replace('/;?\\}/S', '}
497 ', $matches[9]);
498  // Add a touch of formatting.
499  return $matches[9];
500  }
501  return $matches[0] . '
502 /* ERROR! Unexpected _proccess_css_minify() parameter */
503 ';
504  }
505 
512  public function compressJsFiles(array $jsFiles)
513  {
514  $filesAfterCompression = array();
515  foreach ($jsFiles as $fileName => $fileOptions) {
516  // If compression is enabled
517  if ($fileOptions['compress']) {
518  $compressedFilename = $this->compressJsFile($fileOptions['file']);
519  $fileOptions['compress'] = false;
520  $fileOptions['file'] = $compressedFilename;
521  $filesAfterCompression[$compressedFilename] = $fileOptions;
522  } else {
523  $filesAfterCompression[$fileName] = $fileOptions;
524  }
525  }
526  return $filesAfterCompression;
527  }
528 
535  public function compressJsFile($filename)
536  {
537  // generate the unique name of the file
538  $filenameAbsolute = GeneralUtility::resolveBackPath($this->rootPath . $this->getFilenameFromMainDir($filename));
539  if (@file_exists($filenameAbsolute)) {
540  $fileStatus = stat($filenameAbsolute);
541  $unique = $filenameAbsolute . $fileStatus['mtime'] . $fileStatus['size'];
542  } else {
543  $unique = $filenameAbsolute;
544  }
545  $pathinfo = PathUtility::pathinfo($filename);
546  $targetFile = $this->targetDirectory . $pathinfo['filename'] . '-' . md5($unique) . '.js';
547  // only create it, if it doesn't exist, yet
548  if (!file_exists((PATH_site . $targetFile)) || $this->createGzipped && !file_exists((PATH_site . $targetFile . '.gzip'))) {
549  $contents = GeneralUtility::getUrl($filenameAbsolute);
550  $this->writeFileAndCompressed($targetFile, $contents);
551  }
552  return $this->relativePath . $this->returnFileReference($targetFile);
553  }
554 
561  protected function getFilenameFromMainDir($filename)
562  {
563  // if BACK_PATH is empty return $filename
564  if (empty($this->backPath)) {
565  return $filename;
566  }
567  // if the file exists in the root path, just return the $filename
568  if (strpos($filename, $this->backPath) === 0) {
569  $file = str_replace($this->backPath, '', $filename);
570  if (is_file(GeneralUtility::resolveBackPath($this->rootPath . $file))) {
571  return $file;
572  }
573  }
574  // if the file is from a special TYPO3 internal directory, add the missing typo3/ prefix
575  if (is_file(realpath(PATH_site . TYPO3_mainDir . $filename))) {
576  $filename = TYPO3_mainDir . $filename;
577  }
578  // build the file path relatively to the PATH_site
579  $backPath = str_replace(TYPO3_mainDir, '', $this->backPath);
580  $file = str_replace($backPath, '', $filename);
581  if (substr($file, 0, 3) === '../') {
582  $file = GeneralUtility::resolveBackPath(PATH_typo3 . $file);
583  } else {
584  $file = PATH_site . $file;
585  }
586  // check if the file exists, and if so, return the path relative to TYPO3_mainDir
587  if (is_file($file)) {
588  $mainDirDepth = substr_count(TYPO3_mainDir, '/');
589  return str_repeat('../', $mainDirDepth) . str_replace(PATH_site, '', $file);
590  }
591  // none of above conditions were met, fallback to default behaviour
592  return substr($filename, strlen($this->backPath));
593  }
594 
602  protected function checkBaseDirectory($filename, array $baseDirectories)
603  {
604  foreach ($baseDirectories as $baseDirectory) {
605  // check, if $filename starts with base directory
606  if (GeneralUtility::isFirstPartOfStr($filename, $baseDirectory)) {
607  return true;
608  }
609  }
610  return false;
611  }
612 
620  protected function cssFixRelativeUrlPaths($contents, $oldDir)
621  {
622  $mainDir = TYPO3_MODE === 'BE' ? TYPO3_mainDir : '';
623  $newDir = '../../' . $mainDir . $oldDir;
624  // Replace "url()" paths
625  if (stripos($contents, 'url') !== false) {
626  $regex = '/url(\\(\\s*["\']?(?!\\/)([^"\']+)["\']?\\s*\\))/iU';
627  $contents = $this->findAndReplaceUrlPathsByRegex($contents, $regex, $newDir, '(\'|\')');
628  }
629  // Replace "@import" paths
630  if (stripos($contents, '@import') !== false) {
631  $regex = '/@import\\s*(["\']?(?!\\/)([^"\']+)["\']?)/i';
632  $contents = $this->findAndReplaceUrlPathsByRegex($contents, $regex, $newDir, '"|"');
633  }
634  return $contents;
635  }
636 
646  protected function findAndReplaceUrlPathsByRegex($contents, $regex, $newDir, $wrap = '|')
647  {
648  $matches = array();
649  $replacements = array();
650  $wrap = explode('|', $wrap);
651  preg_match_all($regex, $contents, $matches);
652  foreach ($matches[2] as $matchCount => $match) {
653  // remove '," or white-spaces around
654  $match = trim($match, '\'" ');
655  // we must not rewrite paths containing ":" or "url(", e.g. data URIs (see RFC 2397)
656  if (strpos($match, ':') === false && !preg_match('/url\\s*\\(/i', $match)) {
657  $newPath = GeneralUtility::resolveBackPath($newDir . $match);
658  $replacements[$matches[1][$matchCount]] = $wrap[0] . $newPath . $wrap[1];
659  }
660  }
661  // replace URL paths in content
662  if (!empty($replacements)) {
663  $contents = str_replace(array_keys($replacements), array_values($replacements), $contents);
664  }
665  return $contents;
666  }
667 
675  protected function cssFixStatements($contents)
676  {
677  $matches = array();
678  $comment = LF . '/* moved by compressor */' . LF;
679  // nothing to do, so just return contents
680  if (stripos($contents, '@charset') === false && stripos($contents, '@import') === false && stripos($contents, '@namespace') === false) {
681  return $contents;
682  }
683  $regex = '/@(charset|import|namespace)\\s*(url)?\\s*\\(?\\s*["\']?[^"\'\\)]+["\']?\\s*\\)?\\s*;/i';
684  preg_match_all($regex, $contents, $matches);
685  if (!empty($matches[0])) {
686  // remove existing statements
687  $contents = str_replace($matches[0], '', $contents);
688  // add statements to the top of contents in the order they occur in original file
689  $contents = $comment . implode($comment, $matches[0]) . LF . trim($contents);
690  }
691  return $contents;
692  }
693 
701  protected function writeFileAndCompressed($filename, $contents)
702  {
703  // write uncompressed file
704  GeneralUtility::writeFile(PATH_site . $filename, $contents);
705  if ($this->createGzipped) {
706  // create compressed version
707  GeneralUtility::writeFile(PATH_site . $filename . '.gzip', gzencode($contents, $this->gzipCompressionLevel));
708  }
709  }
710 
718  protected function returnFileReference($filename)
719  {
720  // if the client accepts gzip and we can create gzipped files, we give him compressed versions
721  if ($this->createGzipped && strpos(GeneralUtility::getIndpEnv('HTTP_ACCEPT_ENCODING'), 'gzip') !== false) {
722  return $filename . '.gzip';
723  } else {
724  return $filename;
725  }
726  }
727 
734  protected function retrieveExternalFile($url)
735  {
736  $externalContent = GeneralUtility::getUrl($url);
737  $filename = $this->targetDirectory . 'external-' . md5($url);
738  // write only if file does not exist and md5 of the content is not the same as fetched one
739  if (!file_exists(PATH_site . $filename)
740  && (md5($externalContent) !== md5(GeneralUtility::getUrl(PATH_site . $filename)))
741  ) {
742  GeneralUtility::writeFile(PATH_site . $filename, $externalContent);
743  }
744  return $filename;
745  }
746 
753  protected function compressCssString($contents)
754  {
755  // Remove multiple charset declarations for standards compliance (and fixing Safari problems).
756  $contents = preg_replace('/^@charset\s+[\'"](\S*?)\b[\'"];/i', '', $contents);
757  // Perform some safe CSS optimizations.
758  // Regexp to match comment blocks.
759  $comment = '/\*[^*]*\*+(?:[^/*][^*]*\*+)*/';
760  // Regexp to match double quoted strings.
761  $double_quot = '"[^"\\\\]*(?:\\\\.[^"\\\\]*)*"';
762  // Regexp to match single quoted strings.
763  $single_quot = "'[^'\\\\]*(?:\\\\.[^'\\\\]*)*'";
764  // Strip all comment blocks, but keep double/single quoted strings.
765  $contents = preg_replace(
766  "<($double_quot|$single_quot)|$comment>Ss",
767  "$1",
768  $contents
769  );
770  // Remove certain whitespace.
771  // There are different conditions for removing leading and trailing
772  // whitespace.
773  // @see http://php.net/manual/regexp.reference.subpatterns.php
774  $contents = preg_replace('<
775  # Strip leading and trailing whitespace.
776  \s*([@{};,])\s*
777  # Strip only leading whitespace from:
778  # - Closing parenthesis: Retain "@media (bar) and foo".
779  | \s+([\)])
780  # Strip only trailing whitespace from:
781  # - Opening parenthesis: Retain "@media (bar) and foo".
782  # - Colon: Retain :pseudo-selectors.
783  | ([\(:])\s+
784  >xS',
785  // Only one of the three capturing groups will match, so its reference
786  // will contain the wanted value and the references for the
787  // two non-matching groups will be replaced with empty strings.
788  '$1$2$3',
789  $contents
790  );
791  // End the file with a new line.
792  $contents = trim($contents);
793  // Ensure file ends in newline.
794  $contents .= LF;
795  return $contents;
796  }
797 }