bfd84089f63d940f0c57577170251bcdfab39f3c
[Packages/TYPO3.CMS.git] / typo3 / sysext / core / Classes / Resource / ResourceCompressor.php
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
17 use TYPO3\CMS\Core\Core\Environment;
18 use TYPO3\CMS\Core\Utility\GeneralUtility;
19 use TYPO3\CMS\Core\Utility\MathUtility;
20 use TYPO3\CMS\Core\Utility\PathUtility;
21
22 /**
23 * Compressor
24 * This merges and compresses CSS and JavaScript files of the TYPO3 Backend.
25 */
26 class ResourceCompressor
27 {
28 /**
29 * @var string
30 */
31 protected $targetDirectory = 'typo3temp/assets/compressed/';
32
33 /**
34 * @var string
35 */
36 protected $rootPath = '';
37
38 /**
39 * gzipped versions are only created if $TYPO3_CONF_VARS[TYPO3_MODE]['compressionLevel'] is set
40 *
41 * @var bool
42 */
43 protected $createGzipped = false;
44
45 /**
46 * @var int
47 */
48 protected $gzipCompressionLevel = -1;
49
50 /**
51 * @var string
52 */
53 protected $htaccessTemplate = '<FilesMatch "\\.(js|css)(\\.gzip)?$">
54 <IfModule mod_expires.c>
55 ExpiresActive on
56 ExpiresDefault "access plus 7 days"
57 </IfModule>
58 FileETag MTime Size
59 </FilesMatch>';
60
61 /**
62 * Constructor
63 */
64 public function __construct()
65 {
66 // we check for existence of our targetDirectory
67 if (!is_dir(Environment::getPublicPath() . '/' . $this->targetDirectory)) {
68 GeneralUtility::mkdir_deep(Environment::getPublicPath() . '/' . $this->targetDirectory);
69 }
70 // if enabled, we check whether we should auto-create the .htaccess file
71 if ($GLOBALS['TYPO3_CONF_VARS']['SYS']['generateApacheHtaccess']) {
72 // check whether .htaccess exists
73 $htaccessPath = Environment::getPublicPath() . '/' . $this->targetDirectory . '.htaccess';
74 if (!file_exists($htaccessPath)) {
75 GeneralUtility::writeFile($htaccessPath, $this->htaccessTemplate);
76 }
77 }
78 // decide whether we should create gzipped versions or not
79 $compressionLevel = $GLOBALS['TYPO3_CONF_VARS'][TYPO3_MODE]['compressionLevel'];
80 // we need zlib for gzencode()
81 if (extension_loaded('zlib') && $compressionLevel) {
82 $this->createGzipped = true;
83 // $compressionLevel can also be TRUE
84 if (MathUtility::canBeInterpretedAsInteger($compressionLevel)) {
85 $this->gzipCompressionLevel = (int)$compressionLevel;
86 }
87 }
88 $this->setRootPath(TYPO3_MODE === 'BE' ? Environment::getPublicPath() . '/typo3/' : Environment::getPublicPath() . '/');
89 }
90
91 /**
92 * Sets absolute path to working directory
93 *
94 * @param string $rootPath Absolute path
95 */
96 public function setRootPath($rootPath)
97 {
98 if (is_string($rootPath)) {
99 $this->rootPath = $rootPath;
100 }
101 }
102
103 /**
104 * Concatenates the Stylesheet files
105 *
106 * Options:
107 * baseDirectories If set, only include files below one of the base directories
108 *
109 * @param array $cssFiles CSS files to process
110 * @param array $options Additional options
111 * @return array CSS files
112 */
113 public function concatenateCssFiles(array $cssFiles, array $options = [])
114 {
115 $filesToIncludeByType = ['all' => []];
116 foreach ($cssFiles as $key => $fileOptions) {
117 // no concatenation allowed for this file, so continue
118 if (!empty($fileOptions['excludeFromConcatenation'])) {
119 continue;
120 }
121 $filenameFromMainDir = $this->getFilenameFromMainDir($fileOptions['file']);
122 // if $options['baseDirectories'] set, we only include files below these directories
123 if (
124 !isset($options['baseDirectories'])
125 || $this->checkBaseDirectory(
126 $filenameFromMainDir,
127 array_merge($options['baseDirectories'], [$this->targetDirectory])
128 )
129 ) {
130 $type = isset($fileOptions['media']) ? strtolower($fileOptions['media']) : 'all';
131 if (!isset($filesToIncludeByType[$type])) {
132 $filesToIncludeByType[$type] = [];
133 }
134 if (!empty($fileOptions['forceOnTop'])) {
135 array_unshift($filesToIncludeByType[$type], $filenameFromMainDir);
136 } else {
137 $filesToIncludeByType[$type][] = $filenameFromMainDir;
138 }
139 // remove the file from the incoming file array
140 unset($cssFiles[$key]);
141 }
142 }
143 if (!empty($filesToIncludeByType)) {
144 foreach ($filesToIncludeByType as $mediaOption => $filesToInclude) {
145 if (empty($filesToInclude)) {
146 continue;
147 }
148 $targetFile = $this->createMergedCssFile($filesToInclude);
149 $concatenatedOptions = [
150 'file' => $targetFile,
151 'rel' => 'stylesheet',
152 'media' => $mediaOption,
153 'compress' => true,
154 'excludeFromConcatenation' => true,
155 'forceOnTop' => false,
156 'allWrap' => ''
157 ];
158 // place the merged stylesheet on top of the stylesheets
159 $cssFiles = array_merge($cssFiles, [$targetFile => $concatenatedOptions]);
160 }
161 }
162 return $cssFiles;
163 }
164
165 /**
166 * Concatenates the JavaScript files
167 *
168 * @param array $jsFiles JavaScript files to process
169 * @return array JS files
170 */
171 public function concatenateJsFiles(array $jsFiles)
172 {
173 $concatenatedJsFileIsAsync = false;
174 $allFilesToConcatenateAreAsync = true;
175 $filesToInclude = [];
176 foreach ($jsFiles as $key => $fileOptions) {
177 // invalid section found or no concatenation allowed, so continue
178 if (empty($fileOptions['section']) || !empty($fileOptions['excludeFromConcatenation'])) {
179 continue;
180 }
181 if (!isset($filesToInclude[$fileOptions['section']])) {
182 $filesToInclude[$fileOptions['section']] = [];
183 }
184 $filenameFromMainDir = $this->getFilenameFromMainDir($fileOptions['file']);
185 if (!empty($fileOptions['forceOnTop'])) {
186 array_unshift($filesToInclude[$fileOptions['section']], $filenameFromMainDir);
187 } else {
188 $filesToInclude[$fileOptions['section']][] = $filenameFromMainDir;
189 }
190 if (!empty($fileOptions['async']) && (bool)$fileOptions['async']) {
191 $concatenatedJsFileIsAsync = true;
192 } else {
193 $allFilesToConcatenateAreAsync = false;
194 }
195 // remove the file from the incoming file array
196 unset($jsFiles[$key]);
197 }
198 if (!empty($filesToInclude)) {
199 foreach ($filesToInclude as $section => $files) {
200 $targetFile = $this->createMergedJsFile($files);
201 $concatenatedOptions = [
202 'file' => $targetFile,
203 'type' => 'text/javascript',
204 'section' => $section,
205 'compress' => true,
206 'excludeFromConcatenation' => true,
207 'forceOnTop' => false,
208 'allWrap' => '',
209 'async' => $concatenatedJsFileIsAsync && $allFilesToConcatenateAreAsync,
210 ];
211 // place the merged javascript on top of the JS files
212 $jsFiles = array_merge([$targetFile => $concatenatedOptions], $jsFiles);
213 }
214 }
215 return $jsFiles;
216 }
217
218 /**
219 * Creates a merged CSS file
220 *
221 * @param array $filesToInclude Files which should be merged, paths relative to root path
222 * @return mixed Filename of the merged file
223 */
224 protected function createMergedCssFile(array $filesToInclude)
225 {
226 return $this->createMergedFile($filesToInclude, 'css');
227 }
228
229 /**
230 * Creates a merged JS file
231 *
232 * @param array $filesToInclude Files which should be merged, paths relative to root path
233 * @return mixed Filename of the merged file
234 */
235 protected function createMergedJsFile(array $filesToInclude)
236 {
237 return $this->createMergedFile($filesToInclude, 'js');
238 }
239
240 /**
241 * Creates a merged file with given file type
242 *
243 * @param array $filesToInclude Files which should be merged, paths relative to root path
244 * @param string $type File type
245 *
246 * @throws \InvalidArgumentException
247 * @return mixed Filename of the merged file
248 */
249 protected function createMergedFile(array $filesToInclude, $type = 'css')
250 {
251 // Get file type
252 $type = strtolower(trim($type, '. '));
253 if (empty($type)) {
254 throw new \InvalidArgumentException('No valid file type given for files to be merged.', 1308957498);
255 }
256 // we add up the filenames, filemtimes and filsizes to later build a checksum over
257 // it and include it in the temporary file name
258 $unique = '';
259 foreach ($filesToInclude as $key => $filename) {
260 if (GeneralUtility::isValidUrl($filename)) {
261 // check if it is possibly a local file with fully qualified URL
262 if (GeneralUtility::isOnCurrentHost($filename) &&
263 GeneralUtility::isFirstPartOfStr(
264 $filename,
265 GeneralUtility::getIndpEnv('TYPO3_SITE_URL')
266 )
267 ) {
268 // attempt to turn it into a local file path
269 $localFilename = substr($filename, strlen(GeneralUtility::getIndpEnv('TYPO3_SITE_URL')));
270 if (@is_file(GeneralUtility::resolveBackPath($this->rootPath . $localFilename))) {
271 $filesToInclude[$key] = $localFilename;
272 } else {
273 $filesToInclude[$key] = $this->retrieveExternalFile($filename);
274 }
275 } else {
276 $filesToInclude[$key] = $this->retrieveExternalFile($filename);
277 }
278 $filename = $filesToInclude[$key];
279 }
280 $filenameAbsolute = GeneralUtility::resolveBackPath($this->rootPath . $filename);
281 if (@file_exists($filenameAbsolute)) {
282 $fileStatus = stat($filenameAbsolute);
283 $unique .= $filenameAbsolute . $fileStatus['mtime'] . $fileStatus['size'];
284 } else {
285 $unique .= $filenameAbsolute;
286 }
287 }
288 $targetFile = $this->targetDirectory . 'merged-' . md5($unique) . '.' . $type;
289 // if the file doesn't already exist, we create it
290 if (!file_exists(Environment::getPublicPath() . '/' . $targetFile)) {
291 $concatenated = '';
292 // concatenate all the files together
293 foreach ($filesToInclude as $filename) {
294 $filenameAbsolute = GeneralUtility::resolveBackPath($this->rootPath . $filename);
295 $filename = PathUtility::stripPathSitePrefix($filenameAbsolute);
296 $contents = file_get_contents($filenameAbsolute);
297 // remove any UTF-8 byte order mark (BOM) from files
298 if (strpos($contents, "\xEF\xBB\xBF") === 0) {
299 $contents = substr($contents, 3);
300 }
301 // only fix paths if files aren't already in typo3temp (already processed)
302 if ($type === 'css' && !GeneralUtility::isFirstPartOfStr($filename, $this->targetDirectory)) {
303 $contents = $this->cssFixRelativeUrlPaths($contents, PathUtility::dirname($filename) . '/');
304 }
305 $concatenated .= LF . $contents;
306 }
307 // move @charset, @import and @namespace statements to top of new file
308 if ($type === 'css') {
309 $concatenated = $this->cssFixStatements($concatenated);
310 }
311 GeneralUtility::writeFile(Environment::getPublicPath() . '/' . $targetFile, $concatenated);
312 }
313 return $targetFile;
314 }
315
316 /**
317 * Compress multiple css files
318 *
319 * @param array $cssFiles The files to compress (array key = filename), relative to requested page
320 * @return array The CSS files after compression (array key = new filename), relative to requested page
321 */
322 public function compressCssFiles(array $cssFiles)
323 {
324 $filesAfterCompression = [];
325 foreach ($cssFiles as $key => $fileOptions) {
326 // if compression is enabled
327 if ($fileOptions['compress']) {
328 $filename = $this->compressCssFile($fileOptions['file']);
329 $fileOptions['compress'] = false;
330 $fileOptions['file'] = $filename;
331 $filesAfterCompression[$filename] = $fileOptions;
332 } else {
333 $filesAfterCompression[$key] = $fileOptions;
334 }
335 }
336 return $filesAfterCompression;
337 }
338
339 /**
340 * Compresses a CSS file
341 *
342 * Options:
343 * baseDirectories If set, only include files below one of the base directories
344 *
345 * removes comments and whitespaces
346 * Adopted from https://github.com/drupal/drupal/blob/8.0.x/core/lib/Drupal/Core/Asset/CssOptimizer.php
347 *
348 * @param string $filename Source filename, relative to requested page
349 * @return string Compressed filename, relative to requested page
350 */
351 public function compressCssFile($filename)
352 {
353 // generate the unique name of the file
354 $filenameAbsolute = GeneralUtility::resolveBackPath($this->rootPath . $this->getFilenameFromMainDir($filename));
355 if (@file_exists($filenameAbsolute)) {
356 $fileStatus = stat($filenameAbsolute);
357 $unique = $filenameAbsolute . $fileStatus['mtime'] . $fileStatus['size'];
358 } else {
359 $unique = $filenameAbsolute;
360 }
361 // make sure it is again the full filename
362 $filename = PathUtility::stripPathSitePrefix($filenameAbsolute);
363
364 $pathinfo = PathUtility::pathinfo($filenameAbsolute);
365 $targetFile = $this->targetDirectory . $pathinfo['filename'] . '-' . md5($unique) . '.css';
366 // only create it, if it doesn't exist, yet
367 if (!file_exists(Environment::getPublicPath() . '/' . $targetFile) || $this->createGzipped && !file_exists(Environment::getPublicPath() . '/' . $targetFile . '.gzip')) {
368 $contents = $this->compressCssString(file_get_contents($filenameAbsolute));
369 if (strpos($filename, $this->targetDirectory) === false) {
370 $contents = $this->cssFixRelativeUrlPaths($contents, PathUtility::dirname($filename) . '/');
371 }
372 $this->writeFileAndCompressed($targetFile, $contents);
373 }
374 return $this->returnFileReference($targetFile);
375 }
376
377 /**
378 * Compress multiple javascript files
379 *
380 * @param array $jsFiles The files to compress (array key = filename), relative to requested page
381 * @return array The js files after compression (array key = new filename), relative to requested page
382 */
383 public function compressJsFiles(array $jsFiles)
384 {
385 $filesAfterCompression = [];
386 foreach ($jsFiles as $fileName => $fileOptions) {
387 // If compression is enabled
388 if ($fileOptions['compress']) {
389 $compressedFilename = $this->compressJsFile($fileOptions['file']);
390 $fileOptions['compress'] = false;
391 $fileOptions['file'] = $compressedFilename;
392 $filesAfterCompression[$compressedFilename] = $fileOptions;
393 } else {
394 $filesAfterCompression[$fileName] = $fileOptions;
395 }
396 }
397 return $filesAfterCompression;
398 }
399
400 /**
401 * Compresses a javascript file
402 *
403 * @param string $filename Source filename, relative to requested page
404 * @return string Filename of the compressed file, relative to requested page
405 */
406 public function compressJsFile($filename)
407 {
408 // generate the unique name of the file
409 $filenameAbsolute = GeneralUtility::resolveBackPath($this->rootPath . $this->getFilenameFromMainDir($filename));
410 if (@file_exists($filenameAbsolute)) {
411 $fileStatus = stat($filenameAbsolute);
412 $unique = $filenameAbsolute . $fileStatus['mtime'] . $fileStatus['size'];
413 } else {
414 $unique = $filenameAbsolute;
415 }
416 $pathinfo = PathUtility::pathinfo($filename);
417 $targetFile = $this->targetDirectory . $pathinfo['filename'] . '-' . md5($unique) . '.js';
418 // only create it, if it doesn't exist, yet
419 if (!file_exists(Environment::getPublicPath() . '/' . $targetFile) || $this->createGzipped && !file_exists(Environment::getPublicPath() . '/' . $targetFile . '.gzip')) {
420 $contents = file_get_contents($filenameAbsolute);
421 $this->writeFileAndCompressed($targetFile, $contents);
422 }
423 return $this->returnFileReference($targetFile);
424 }
425
426 /**
427 * Finds the relative path to a file, relative to the root path.
428 *
429 * @param string $filename the name of the file
430 * @return string the path to the file relative to the root path ($this->rootPath)
431 */
432 protected function getFilenameFromMainDir($filename)
433 {
434 /*
435 * The various paths may have those values (e.g. if TYPO3 is installed in a subdir)
436 * - docRoot = /var/www/html/
437 * - Environment::getPublicPath() = /var/www/html/sites/site1/
438 * - $this->rootPath = /var/www/html/sites/site1/typo3
439 *
440 * The file names passed into this function may be either:
441 * - relative to $this->rootPath
442 * - relative to Environment::getPublicPath()
443 * - relative to docRoot
444 */
445 $docRoot = GeneralUtility::getIndpEnv('TYPO3_DOCUMENT_ROOT');
446 $fileNameWithoutSlash = ltrim($filename, '/');
447
448 // if the file is an absolute reference within the docRoot
449 $absolutePath = $docRoot . '/' . $fileNameWithoutSlash;
450 // Calling is_file without @ for a path starting with '../' causes a PHP Warning when using open_basedir restriction
451 if (@is_file($absolutePath)) {
452 if (strpos($absolutePath, $this->rootPath) === 0) {
453 // the path is within the current root path, simply strip rootPath off
454 return substr($absolutePath, strlen($this->rootPath));
455 }
456 // the path is not within the root path, strip off the site path, the remaining logic below
457 // takes care about adjusting the path correctly.
458 $filename = substr($absolutePath, strlen(Environment::getPublicPath() . '/'));
459 }
460 // if the file exists in the root path, just return the $filename
461 if (is_file($this->rootPath . $fileNameWithoutSlash)) {
462 return $fileNameWithoutSlash;
463 }
464 // if the file is from a special TYPO3 internal directory, add the missing typo3/ prefix
465 if (is_file(realpath(Environment::getPublicPath() . '/typo3/' . $filename))) {
466 $filename = 'typo3/' . $filename;
467 }
468 // build the file path relative to the public web path
469 if (strpos($filename, 'EXT:') === 0) {
470 $file = GeneralUtility::getFileAbsFileName($filename);
471 } elseif (strpos($filename, '../') === 0) {
472 $file = GeneralUtility::resolveBackPath(Environment::getPublicPath() . '/typo3/' . $filename);
473 } else {
474 $file = Environment::getPublicPath() . '/' . $filename;
475 }
476
477 // check if the file exists, and if so, return the path relative to TYPO3_mainDir
478 if (is_file($file)) {
479 return '../' . str_replace(Environment::getPublicPath() . '/', '', $file);
480 }
481 // none of above conditions were met, fallback to default behaviour
482 return $filename;
483 }
484
485 /**
486 * Decides whether a file comes from one of the baseDirectories
487 *
488 * @param string $filename Filename
489 * @param array $baseDirectories Base directories
490 * @return bool File belongs to a base directory or not
491 */
492 protected function checkBaseDirectory($filename, array $baseDirectories)
493 {
494 foreach ($baseDirectories as $baseDirectory) {
495 // check, if $filename starts with base directory
496 if (GeneralUtility::isFirstPartOfStr($filename, $baseDirectory)) {
497 return true;
498 }
499 }
500 return false;
501 }
502
503 /**
504 * Fixes the relative paths inside of url() references in CSS files
505 *
506 * @param string $contents Data to process
507 * @param string $oldDir Directory of the original file, relative to TYPO3_mainDir
508 * @return string Processed data
509 */
510 protected function cssFixRelativeUrlPaths($contents, $oldDir)
511 {
512 $newDir = '../../../' . $oldDir;
513 // Replace "url()" paths
514 if (stripos($contents, 'url') !== false) {
515 $regex = '/url(\\(\\s*["\']?(?!\\/)([^"\']+)["\']?\\s*\\))/iU';
516 $contents = $this->findAndReplaceUrlPathsByRegex($contents, $regex, $newDir, '(\'|\')');
517 }
518 // Replace "@import" paths
519 if (stripos($contents, '@import') !== false) {
520 $regex = '/@import\\s*(["\']?(?!\\/)([^"\']+)["\']?)/i';
521 $contents = $this->findAndReplaceUrlPathsByRegex($contents, $regex, $newDir, '"|"');
522 }
523 return $contents;
524 }
525
526 /**
527 * Finds and replaces all URLs by using a given regex
528 *
529 * @param string $contents Data to process
530 * @param string $regex Regex used to find URLs in content
531 * @param string $newDir Path to prepend to the original file
532 * @param string $wrap Wrap around replaced values
533 * @return string Processed data
534 */
535 protected function findAndReplaceUrlPathsByRegex($contents, $regex, $newDir, $wrap = '|')
536 {
537 $matches = [];
538 $replacements = [];
539 $wrap = explode('|', $wrap);
540 preg_match_all($regex, $contents, $matches);
541 foreach ($matches[2] as $matchCount => $match) {
542 // remove '," or white-spaces around
543 $match = trim($match, '\'" ');
544 // we must not rewrite paths containing ":" or "url(", e.g. data URIs (see RFC 2397)
545 if (strpos($match, ':') === false && !preg_match('/url\\s*\\(/i', $match)) {
546 $newPath = GeneralUtility::resolveBackPath($newDir . $match);
547 $replacements[$matches[1][$matchCount]] = $wrap[0] . $newPath . $wrap[1];
548 }
549 }
550 // replace URL paths in content
551 if (!empty($replacements)) {
552 $contents = str_replace(array_keys($replacements), array_values($replacements), $contents);
553 }
554 return $contents;
555 }
556
557 /**
558 * Moves @charset, @import and @namespace statements to the top of
559 * the content, because they must occur before all other CSS rules
560 *
561 * @param string $contents Data to process
562 * @return string Processed data
563 */
564 protected function cssFixStatements($contents)
565 {
566 $matches = [];
567 $comment = LF . '/* moved by compressor */' . LF;
568 // nothing to do, so just return contents
569 if (stripos($contents, '@charset') === false && stripos($contents, '@import') === false && stripos($contents, '@namespace') === false) {
570 return $contents;
571 }
572 $regex = '/@(charset|import|namespace)\\s*(url)?\\s*\\(?\\s*["\']?[^"\'\\)]+["\']?\\s*\\)?\\s*;/i';
573 preg_match_all($regex, $contents, $matches);
574 if (!empty($matches[0])) {
575 // Ensure correct order of @charset, @namespace and @import
576 $charset = '';
577 $namespaces = [];
578 $imports = [];
579 foreach ($matches[1] as $index => $keyword) {
580 switch ($keyword) {
581 case 'charset':
582 if (empty($charset)) {
583 $charset = $matches[0][$index];
584 }
585 break;
586 case 'namespace':
587 $namespaces[] = $matches[0][$index];
588 break;
589 case 'import':
590 $imports[] = $matches[0][$index];
591 break;
592 }
593 }
594
595 $namespaces = !empty($namespaces) ? implode('', $namespaces) . $comment : '';
596 $imports = !empty($imports) ? implode('', $imports) . $comment : '';
597 // remove existing statements
598 $contents = str_replace($matches[0], '', $contents);
599 // add statements to the top of contents in the order they occur in original file
600 $contents =
601 $charset
602 . $comment
603 . $namespaces
604 . $imports
605 . trim($contents);
606 }
607 return $contents;
608 }
609
610 /**
611 * Writes $contents into file $filename together with a gzipped version into $filename.gz
612 *
613 * @param string $filename Target filename
614 * @param string $contents File contents
615 */
616 protected function writeFileAndCompressed($filename, $contents)
617 {
618 // write uncompressed file
619 GeneralUtility::writeFile(Environment::getPublicPath() . '/' . $filename, $contents);
620 if ($this->createGzipped) {
621 // create compressed version
622 GeneralUtility::writeFile(Environment::getPublicPath() . '/' . $filename . '.gzip', gzencode($contents, $this->gzipCompressionLevel));
623 }
624 }
625
626 /**
627 * Decides whether a client can deal with gzipped content or not and returns the according file name,
628 * based on HTTP_ACCEPT_ENCODING
629 *
630 * @param string $filename File name
631 * @return string $filename suffixed with '.gzip' or not - dependent on HTTP_ACCEPT_ENCODING
632 */
633 protected function returnFileReference($filename)
634 {
635 // if the client accepts gzip and we can create gzipped files, we give him compressed versions
636 if ($this->createGzipped && strpos(GeneralUtility::getIndpEnv('HTTP_ACCEPT_ENCODING'), 'gzip') !== false) {
637 $filename .= '.gzip';
638 }
639 return PathUtility::getRelativePath($this->rootPath, Environment::getPublicPath() . '/') . $filename;
640 }
641
642 /**
643 * Retrieves an external file and stores it locally.
644 *
645 * @param string $url
646 * @return string Temporary local filename for the externally-retrieved file
647 */
648 protected function retrieveExternalFile($url)
649 {
650 $externalContent = GeneralUtility::getUrl($url);
651 $filename = $this->targetDirectory . 'external-' . md5($url);
652 // Write only if file does not exist OR md5 of the content is not the same as fetched one
653 if (!file_exists(Environment::getPublicPath() . '/' . $filename)
654 || (md5($externalContent) !== md5(file_get_contents(Environment::getPublicPath() . '/' . $filename)))
655 ) {
656 GeneralUtility::writeFile(Environment::getPublicPath() . '/' . $filename, $externalContent);
657 }
658 return $filename;
659 }
660
661 /**
662 * Compress a CSS string by removing comments and whitespace characters
663 *
664 * @param string $contents
665 * @return string
666 */
667 protected function compressCssString($contents)
668 {
669 // Perform some safe CSS optimizations.
670 // Regexp to match comment blocks.
671 $comment = '/\*[^*]*\*+(?:[^/*][^*]*\*+)*/';
672 // Regexp to match double quoted strings.
673 $double_quot = '"[^"\\\\]*(?:\\\\.[^"\\\\]*)*"';
674 // Regexp to match single quoted strings.
675 $single_quot = "'[^'\\\\]*(?:\\\\.[^'\\\\]*)*'";
676 // Strip all comment blocks, but keep double/single quoted strings.
677 $contents = preg_replace(
678 "<($double_quot|$single_quot)|$comment>Ss",
679 '$1',
680 $contents
681 );
682 // Remove certain whitespace.
683 // There are different conditions for removing leading and trailing
684 // whitespace.
685 // @see http://php.net/manual/regexp.reference.subpatterns.php
686 $contents = preg_replace(
687 '<
688 # Strip leading and trailing whitespace.
689 \s*([@{};,])\s*
690 # Strip only leading whitespace from:
691 # - Closing parenthesis: Retain "@media (bar) and foo".
692 | \s+([\)])
693 # Strip only trailing whitespace from:
694 # - Opening parenthesis: Retain "@media (bar) and foo".
695 # - Colon: Retain :pseudo-selectors.
696 | ([\(:])\s+
697 >xS',
698 // Only one of the three capturing groups will match, so its reference
699 // will contain the wanted value and the references for the
700 // two non-matching groups will be replaced with empty strings.
701 '$1$2$3',
702 $contents
703 );
704 // End the file with a new line.
705 $contents = trim($contents);
706 // Ensure file ends in newline.
707 $contents .= LF;
708 return $contents;
709 }
710 }