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