Passed
Push — master ( c193c0...685f00 )
by Roeland
09:30 queued 10s
created

SCSSCacher::injectCssVariablesIfAny()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 3
nc 2
nop 0
dl 0
loc 6
rs 10
c 0
b 0
f 0
1
<?php
2
/**
3
 * @copyright Copyright (c) 2016, John Molakvoæ ([email protected])
4
 *
5
 * @author John Molakvoæ (skjnldsv) <[email protected]>
6
 * @author Julius Haertl <[email protected]>
7
 * @author Julius Härtl <[email protected]>
8
 * @author Lukas Reschke <[email protected]>
9
 * @author Morris Jobke <[email protected]>
10
 * @author Roeland Jago Douma <[email protected]>
11
 *
12
 * @license GNU AGPL version 3 or any later version
13
 *
14
 * This program is free software: you can redistribute it and/or modify
15
 * it under the terms of the GNU Affero General Public License as
16
 * published by the Free Software Foundation, either version 3 of the
17
 * License, or (at your option) any later version.
18
 *
19
 * This program is distributed in the hope that it will be useful,
20
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
21
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
22
 * GNU Affero General Public License for more details.
23
 *
24
 * You should have received a copy of the GNU Affero General Public License
25
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
26
 *
27
 */
28
29
namespace OC\Template;
30
31
use Leafo\ScssPhp\Compiler;
32
use Leafo\ScssPhp\Exception\ParserException;
33
use Leafo\ScssPhp\Formatter\Crunched;
34
use Leafo\ScssPhp\Formatter\Expanded;
35
use OC\Memcache\NullCache;
36
use OCP\AppFramework\Utility\ITimeFactory;
37
use OCP\Files\IAppData;
38
use OCP\Files\NotFoundException;
39
use OCP\Files\NotPermittedException;
40
use OCP\Files\SimpleFS\ISimpleFile;
41
use OCP\Files\SimpleFS\ISimpleFolder;
42
use OCP\ICache;
43
use OCP\ICacheFactory;
44
use OCP\IConfig;
45
use OCP\ILogger;
46
use OCP\IMemcache;
47
use OCP\IURLGenerator;
48
use OC\Files\AppData\Factory;
49
use OC\Template\IconsCacher;
50
51
class SCSSCacher {
52
53
	/** @var ILogger */
54
	protected $logger;
55
56
	/** @var IAppData */
57
	protected $appData;
58
59
	/** @var IURLGenerator */
60
	protected $urlGenerator;
61
62
	/** @var IConfig */
63
	protected $config;
64
65
	/** @var \OC_Defaults */
66
	private $defaults;
67
68
	/** @var string */
69
	protected $serverRoot;
70
71
	/** @var ICache */
72
	protected $depsCache;
73
74
	/** @var null|string */
75
	private $injectedVariables;
76
77
	/** @var ICacheFactory */
78
	private $cacheFactory;
79
80
	/** @var IconsCacher */
81
	private $iconsCacher;
82
83
	/** @var ICache */
84
	private $isCachedCache;
85
86
	/** @var ITimeFactory */
87
	private $timeFactory;
88
89
	/** @var IMemcache */
90
	private $lockingCache;
91
92
	/**
93
	 * @param ILogger $logger
94
	 * @param Factory $appDataFactory
95
	 * @param IURLGenerator $urlGenerator
96
	 * @param IConfig $config
97
	 * @param \OC_Defaults $defaults
98
	 * @param string $serverRoot
99
	 * @param ICacheFactory $cacheFactory
100
	 * @param IconsCacher $iconsCacher
101
	 * @param ITimeFactory $timeFactory
102
	 */
103
	public function __construct(ILogger $logger,
104
								Factory $appDataFactory,
105
								IURLGenerator $urlGenerator,
106
								IConfig $config,
107
								\OC_Defaults $defaults,
108
								$serverRoot,
109
								ICacheFactory $cacheFactory,
110
								IconsCacher $iconsCacher,
111
								ITimeFactory $timeFactory) {
112
		$this->logger       = $logger;
113
		$this->appData      = $appDataFactory->get('css');
114
		$this->urlGenerator = $urlGenerator;
115
		$this->config       = $config;
116
		$this->defaults     = $defaults;
117
		$this->serverRoot   = $serverRoot;
118
		$this->cacheFactory = $cacheFactory;
119
		$this->depsCache    = $cacheFactory->createDistributed('SCSS-deps-' . md5($this->urlGenerator->getBaseUrl()));
120
		$this->isCachedCache = $cacheFactory->createLocal('SCSS-cached-' . md5($this->urlGenerator->getBaseUrl()));
121
		$lockingCache = $cacheFactory->createDistributed('SCSS-locks-' . md5($this->urlGenerator->getBaseUrl()));
122
		if (!($lockingCache instanceof IMemcache)) {
123
			$lockingCache = new NullCache();
124
		}
125
		$this->lockingCache = $lockingCache;
126
		$this->iconsCacher = $iconsCacher;
127
		$this->timeFactory = $timeFactory;
128
	}
129
130
	/**
131
	 * Process the caching process if needed
132
	 *
133
	 * @param string $root Root path to the nextcloud installation
134
	 * @param string $file
135
	 * @param string $app The app name
136
	 * @return boolean
137
	 * @throws NotPermittedException
138
	 */
139
	public function process(string $root, string $file, string $app): bool {
140
		$path = explode('/', $root . '/' . $file);
141
142
		$fileNameSCSS = array_pop($path);
143
		$fileNameCSS  = $this->prependVersionPrefix($this->prependBaseurlPrefix(str_replace('.scss', '.css', $fileNameSCSS)), $app);
144
145
		$path   = implode('/', $path);
146
		$webDir = $this->getWebDir($path, $app, $this->serverRoot, \OC::$WEBROOT);
147
148
		if (!$this->variablesChanged() && $this->isCached($fileNameCSS, $app)) {
149
			// Inject icons vars css if any
150
			return $this->injectCssVariablesIfAny();
151
		}
152
153
		try {
154
			$folder = $this->appData->getFolder($app);
155
		} catch (NotFoundException $e) {
156
			// creating css appdata folder
157
			$folder = $this->appData->newFolder($app);
158
		}
159
160
		$lockKey = $webDir . '/' . $fileNameSCSS;
161
162
		if (!$this->lockingCache->add($lockKey, 'locked!', 120)) {
163
			$retry = 0;
164
			sleep(1);
165
			while ($retry < 10) {
166
				if (!$this->variablesChanged() && $this->isCached($fileNameCSS, $app)) {
167
					// Inject icons vars css if any
168
					$this->lockingCache->remove($lockKey);
169
					$this->logger->debug('SCSSCacher: ' .$lockKey.' is now available after '.$retry.'s. Moving on...', ['app' => 'core']);
170
					return $this->injectCssVariablesIfAny();
171
				}
172
				$this->logger->debug('SCSSCacher: scss cache file locked for '.$lockKey, ['app' => 'core']);
173
				sleep($retry);
174
				$retry++;
175
			}
176
			$this->logger->debug('SCSSCacher: Giving up scss caching for '.$lockKey, ['app' => 'core']);
177
			return false;
178
		}
179
180
		try {
181
			$cached = $this->cache($path, $fileNameCSS, $fileNameSCSS, $folder, $webDir);
182
		} catch (\Exception $e) {
183
			$this->lockingCache->remove($lockKey);
184
			throw $e;
185
		}
186
187
		// Cleaning lock
188
		$this->lockingCache->remove($lockKey);
189
190
		// Inject icons vars css if any
191
		if ($this->iconsCacher->getCachedCSS() && $this->iconsCacher->getCachedCSS()->getSize() > 0) {
192
			$this->iconsCacher->injectCss();
193
		}
194
195
		return $cached;
196
	}
197
198
	/**
199
	 * @param $appName
200
	 * @param $fileName
201
	 * @return ISimpleFile
202
	 */
203
	public function getCachedCSS(string $appName, string $fileName): ISimpleFile {
204
		$folder         = $this->appData->getFolder($appName);
205
		$cachedFileName = $this->prependVersionPrefix($this->prependBaseurlPrefix($fileName), $appName);
206
207
		return $folder->getFile($cachedFileName);
208
	}
209
210
	/**
211
	 * Check if the file is cached or not
212
	 * @param string $fileNameCSS
213
	 * @param string $app
214
	 * @return boolean
215
	 */
216
	private function isCached(string $fileNameCSS, string $app) {
217
		$key = $this->config->getSystemValue('version') . '/' . $app . '/' . $fileNameCSS;
218
219
		// If the file mtime is more recent than our cached one,
220
		// let's consider the file is properly cached
221
		if ($cacheValue = $this->isCachedCache->get($key)) {
222
			if ($cacheValue > $this->timeFactory->getTime()) {
223
				return true;
224
			}
225
		}
226
227
		// Creating file cache if none for further checks
228
		try {
229
			$folder = $this->appData->getFolder($app);
230
		} catch (NotFoundException $e) {
231
			return false;
232
		}
233
234
		// Checking if file size is coherent
235
		// and if one of the css dependency changed
236
		try {
237
			$cachedFile = $folder->getFile($fileNameCSS);
238
			if ($cachedFile->getSize() > 0) {
239
				$depFileName = $fileNameCSS . '.deps';
240
				$deps        = $this->depsCache->get($folder->getName() . '-' . $depFileName);
241
				if ($deps === null) {
242
					$depFile = $folder->getFile($depFileName);
243
					$deps    = $depFile->getContent();
244
					// Set to memcache for next run
245
					$this->depsCache->set($folder->getName() . '-' . $depFileName, $deps);
246
				}
247
				$deps = json_decode($deps, true);
248
249
				foreach ((array) $deps as $file => $mtime) {
250
					if (!file_exists($file) || filemtime($file) > $mtime) {
251
						return false;
252
					}
253
				}
254
255
				$this->isCachedCache->set($key, $this->timeFactory->getTime() + 5 * 60);
256
				return true;
257
			}
258
259
			return false;
260
		} catch (NotFoundException $e) {
261
			return false;
262
		}
263
	}
264
265
	/**
266
	 * Check if the variables file has changed
267
	 * @return bool
268
	 */
269
	private function variablesChanged(): bool {
270
		$injectedVariables = $this->getInjectedVariables();
271
		if ($this->config->getAppValue('core', 'theming.variables') !== md5($injectedVariables)) {
272
			$this->resetCache();
273
			$this->config->setAppValue('core', 'theming.variables', md5($injectedVariables));
274
			return true;
275
		}
276
		return false;
277
	}
278
279
	/**
280
	 * Cache the file with AppData
281
	 *
282
	 * @param string $path
283
	 * @param string $fileNameCSS
284
	 * @param string $fileNameSCSS
285
	 * @param ISimpleFolder $folder
286
	 * @param string $webDir
287
	 * @return boolean
288
	 * @throws NotPermittedException
289
	 */
290
	private function cache(string $path, string $fileNameCSS, string $fileNameSCSS, ISimpleFolder $folder, string $webDir) {
291
		$scss = new Compiler();
292
		$scss->setImportPaths([
293
			$path,
294
			$this->serverRoot . '/core/css/'
295
		]);
296
297
		// Continue after throw
298
		$scss->setIgnoreErrors(true);
299
		if ($this->config->getSystemValue('debug')) {
300
			// Debug mode
301
			$scss->setFormatter(Expanded::class);
302
			$scss->setLineNumberStyle(Compiler::LINE_COMMENTS);
303
		} else {
304
			// Compression
305
			$scss->setFormatter(Crunched::class);
306
		}
307
308
		try {
309
			$cachedfile = $folder->getFile($fileNameCSS);
310
		} catch (NotFoundException $e) {
311
			$cachedfile = $folder->newFile($fileNameCSS);
312
		}
313
314
		$depFileName = $fileNameCSS . '.deps';
315
		try {
316
			$depFile = $folder->getFile($depFileName);
317
		} catch (NotFoundException $e) {
318
			$depFile = $folder->newFile($depFileName);
319
		}
320
321
		// Compile
322
		try {
323
			$compiledScss = $scss->compile(
324
				'$webroot: \'' . $this->getRoutePrefix() . '\';' .
325
				$this->getInjectedVariables() .
326
				'@import "variables.scss";' .
327
				'@import "functions.scss";' .
328
				'@import "' . $fileNameSCSS . '";');
329
		} catch (ParserException $e) {
330
			$this->logger->logException($e, ['app' => 'core']);
331
332
			return false;
333
		}
334
335
		// Parse Icons and create related css variables
336
		$compiledScss = $this->iconsCacher->setIconsCss($compiledScss);
337
338
		// Gzip file
339
		try {
340
			$gzipFile = $folder->getFile($fileNameCSS . '.gzip'); # Safari doesn't like .gz
341
		} catch (NotFoundException $e) {
342
			$gzipFile = $folder->newFile($fileNameCSS . '.gzip'); # Safari doesn't like .gz
343
		}
344
345
		try {
346
			$data = $this->rebaseUrls($compiledScss, $webDir);
347
			$cachedfile->putContent($data);
348
			$deps = json_encode($scss->getParsedFiles());
349
			$depFile->putContent($deps);
350
			$this->depsCache->set($folder->getName() . '-' . $depFileName, $deps);
351
			$gzipFile->putContent(gzencode($data, 9));
352
			$this->logger->debug('SCSSCacher: ' . $webDir . '/' . $fileNameSCSS . ' compiled and successfully cached', ['app' => 'core']);
353
354
			return true;
355
		} catch (NotPermittedException $e) {
356
			$this->logger->error('SCSSCacher: unable to cache: ' . $fileNameSCSS);
357
358
			return false;
359
		}
360
	}
361
362
	/**
363
	 * Reset scss cache by deleting all generated css files
364
	 * We need to regenerate all files when variables change
365
	 */
366
	public function resetCache() {
367
		$this->injectedVariables = null;
368
369
		// do not clear locks
370
		$this->cacheFactory->createDistributed('SCSS-deps-')->clear();
371
		$this->cacheFactory->createDistributed('SCSS-cached-')->clear();
372
373
		$appDirectory = $this->appData->getDirectoryListing();
374
		foreach ($appDirectory as $folder) {
375
			foreach ($folder->getDirectoryListing() as $file) {
376
				try {
377
					$file->delete();
378
				} catch (NotPermittedException $e) {
379
					$this->logger->logException($e, ['message' => 'SCSSCacher: unable to delete file: ' . $file->getName()]);
380
				}
381
			}
382
		}
383
		$this->logger->debug('SCSSCacher: css cache cleared!');
384
	}
385
386
	/**
387
	 * @return string SCSS code for variables from OC_Defaults
388
	 */
389
	private function getInjectedVariables(): string {
390
		if ($this->injectedVariables !== null) {
391
			return $this->injectedVariables;
392
		}
393
		$variables = '';
394
		foreach ($this->defaults->getScssVariables() as $key => $value) {
395
			$variables .= '$' . $key . ': ' . $value . ' !default;';
396
		}
397
398
		// check for valid variables / otherwise fall back to defaults
399
		try {
400
			$scss = new Compiler();
401
			$scss->compile($variables);
402
			$this->injectedVariables = $variables;
403
		} catch (ParserException $e) {
404
			$this->logger->logException($e, ['app' => 'core']);
405
		}
406
407
		return $variables;
408
	}
409
410
	/**
411
	 * Add the correct uri prefix to make uri valid again
412
	 * @param string $css
413
	 * @param string $webDir
414
	 * @return string
415
	 */
416
	private function rebaseUrls(string $css, string $webDir): string {
417
		$re    = '/url\([\'"]([^\/][\.\w?=\/-]*)[\'"]\)/x';
418
		$subst = 'url(\'' . $webDir . '/$1\')';
419
420
		return preg_replace($re, $subst, $css);
421
	}
422
423
	/**
424
	 * Return the cached css file uri
425
	 * @param string $appName the app name
426
	 * @param string $fileName
427
	 * @return string
428
	 */
429
	public function getCachedSCSS(string $appName, string $fileName): string {
430
		$tmpfileLoc = explode('/', $fileName);
431
		$fileName   = array_pop($tmpfileLoc);
432
		$fileName   = $this->prependVersionPrefix($this->prependBaseurlPrefix(str_replace('.scss', '.css', $fileName)), $appName);
433
434
		return substr($this->urlGenerator->linkToRoute('core.Css.getCss', [
435
			'fileName' => $fileName,
436
			'appName' => $appName,
437
			'v' => $this->config->getAppValue('core', 'theming.variables', '0')
438
		]), \strlen(\OC::$WEBROOT) + 1);
439
	}
440
441
	/**
442
	 * Prepend hashed base url to the css file
443
	 * @param string $cssFile
444
	 * @return string
445
	 */
446
	private function prependBaseurlPrefix(string $cssFile): string {
447
		return substr(md5($this->urlGenerator->getBaseUrl() . $this->getRoutePrefix()), 0, 4) . '-' . $cssFile;
448
	}
449
450
	private function getRoutePrefix() {
451
		$frontControllerActive = ($this->config->getSystemValue('htaccess.IgnoreFrontController', false) === true || getenv('front_controller_active') === 'true');
452
		$prefix = \OC::$WEBROOT . '/index.php';
453
		if ($frontControllerActive) {
454
			$prefix = \OC::$WEBROOT;
455
		}
456
		return $prefix;
457
	}
458
459
	/**
460
	 * Prepend hashed app version hash
461
	 * @param string $cssFile
462
	 * @param string $appId
463
	 * @return string
464
	 */
465
	private function prependVersionPrefix(string $cssFile, string $appId): string {
466
		$appVersion = \OC_App::getAppVersion($appId);
0 ignored issues
show
Deprecated Code introduced by
The function OC_App::getAppVersion() has been deprecated: 14.0.0 use \OC::$server->getAppManager()->getAppVersion() ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

466
		$appVersion = /** @scrutinizer ignore-deprecated */ \OC_App::getAppVersion($appId);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
467
		if ($appVersion !== '0') {
468
			return substr(md5($appVersion), 0, 4) . '-' . $cssFile;
469
		}
470
		$coreVersion = \OC_Util::getVersionString();
471
472
		return substr(md5($coreVersion), 0, 4) . '-' . $cssFile;
473
	}
474
475
	/**
476
	 * Get WebDir root
477
	 * @param string $path the css file path
478
	 * @param string $appName the app name
479
	 * @param string $serverRoot the server root path
480
	 * @param string $webRoot the nextcloud installation root path
481
	 * @return string the webDir
482
	 */
483
	private function getWebDir(string $path, string $appName, string $serverRoot, string $webRoot): string {
484
		// Detect if path is within server root AND if path is within an app path
485
		if (strpos($path, $serverRoot) === false && $appWebPath = \OC_App::getAppWebPath($appName)) {
486
			// Get the file path within the app directory
487
			$appDirectoryPath = explode($appName, $path)[1];
488
			// Remove the webroot
489
490
			return str_replace($webRoot, '', $appWebPath . $appDirectoryPath);
491
		}
492
493
		return $webRoot . substr($path, strlen($serverRoot));
494
	}
495
496
	/**
497
	 * Add the icons css cache in the header if needed 
498
	 *
499
	 * @return boolean true
500
	 */
501
	private function injectCssVariablesIfAny() {
502
		// Inject icons vars css if any
503
		if ($this->iconsCacher->getCachedCSS() && $this->iconsCacher->getCachedCSS()->getSize() > 0) {
504
			$this->iconsCacher->injectCss();
505
		}
506
		return true;
507
	}
508
}
509