Passed
Push — master ( 18f234...5a0b28 )
by John
16:38 queued 11s
created

SCSSCacher::isCached()   B

Complexity

Conditions 10
Paths 67

Size

Total Lines 52
Code Lines 32

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 10
eloc 32
nc 67
nop 2
dl 0
loc 52
rs 7.6666
c 0
b 0
f 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
/**
3
 * @copyright Copyright (c) 2016, John Molakvoæ ([email protected])
4
 *
5
 * @author Christoph Wurst <[email protected]>
6
 * @author John Molakvoæ <[email protected]>
7
 * @author Julius Haertl <[email protected]>
8
 * @author Julius Härtl <[email protected]>
9
 * @author Morris Jobke <[email protected]>
10
 * @author Robin Appelman <[email protected]>
11
 * @author Roeland Jago Douma <[email protected]>
12
 * @author Roland Tapken <[email protected]>
13
 *
14
 * @license GNU AGPL version 3 or any later version
15
 *
16
 * This program is free software: you can redistribute it and/or modify
17
 * it under the terms of the GNU Affero General Public License as
18
 * published by the Free Software Foundation, either version 3 of the
19
 * License, or (at your option) any later version.
20
 *
21
 * This program is distributed in the hope that it will be useful,
22
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
23
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
24
 * GNU Affero General Public License for more details.
25
 *
26
 * You should have received a copy of the GNU Affero General Public License
27
 * along with this program. If not, see <http://www.gnu.org/licenses/>.
28
 *
29
 */
30
namespace OC\Template;
31
32
use OC\AppConfig;
33
use OC\Files\AppData\Factory;
34
use OC\Memcache\NullCache;
35
use OCP\AppFramework\Utility\ITimeFactory;
36
use OCP\Files\IAppData;
37
use OCP\Files\NotFoundException;
38
use OCP\Files\NotPermittedException;
39
use OCP\Files\SimpleFS\ISimpleFile;
40
use OCP\Files\SimpleFS\ISimpleFolder;
41
use OCP\ICache;
42
use OCP\ICacheFactory;
43
use OCP\IConfig;
44
use OCP\IMemcache;
45
use OCP\IURLGenerator;
46
use Psr\Log\LoggerInterface;
47
use ScssPhp\ScssPhp\Compiler;
48
use ScssPhp\ScssPhp\OutputStyle;
49
50
class SCSSCacher {
51
	protected LoggerInterface $logger;
52
53
	/** @var IAppData */
54
	protected $appData;
55
56
	/** @var IURLGenerator */
57
	protected $urlGenerator;
58
59
	/** @var IConfig */
60
	protected $config;
61
62
	/** @var \OC_Defaults */
63
	private $defaults;
64
65
	/** @var string */
66
	protected $serverRoot;
67
68
	/** @var ICache */
69
	protected $depsCache;
70
71
	/** @var null|string */
72
	private $injectedVariables;
73
74
	/** @var ICacheFactory */
75
	private $cacheFactory;
76
77
	/** @var ICache */
78
	private $isCachedCache;
79
80
	/** @var ITimeFactory */
81
	private $timeFactory;
82
83
	/** @var IMemcache */
84
	private $lockingCache;
85
	/** @var AppConfig */
86
	private $appConfig;
87
88
	/**
89
	 * @param string $serverRoot
90
	 */
91
	public function __construct(LoggerInterface $logger,
92
								Factory $appDataFactory,
93
								IURLGenerator $urlGenerator,
94
								IConfig $config,
95
								\OC_Defaults $defaults,
96
								$serverRoot,
97
								ICacheFactory $cacheFactory,
98
								ITimeFactory $timeFactory,
99
								AppConfig $appConfig) {
100
		$this->logger = $logger;
101
		$this->appData = $appDataFactory->get('css');
102
		$this->urlGenerator = $urlGenerator;
103
		$this->config = $config;
104
		$this->defaults = $defaults;
105
		$this->serverRoot = $serverRoot;
106
		$this->cacheFactory = $cacheFactory;
107
		$this->depsCache = $cacheFactory->createDistributed('SCSS-deps-' . md5($this->urlGenerator->getBaseUrl()));
108
		$this->isCachedCache = $cacheFactory->createDistributed('SCSS-cached-' . md5($this->urlGenerator->getBaseUrl()));
109
		$lockingCache = $cacheFactory->createDistributed('SCSS-locks-' . md5($this->urlGenerator->getBaseUrl()));
110
		if (!($lockingCache instanceof IMemcache)) {
111
			$lockingCache = new NullCache();
112
		}
113
		$this->lockingCache = $lockingCache;
114
		$this->timeFactory = $timeFactory;
115
		$this->appConfig = $appConfig;
116
	}
117
118
	/**
119
	 * Process the caching process if needed
120
	 *
121
	 * @param string $root Root path to the nextcloud installation
122
	 * @param string $file
123
	 * @param string $app The app name
124
	 * @return boolean
125
	 * @throws NotPermittedException
126
	 */
127
	public function process(string $root, string $file, string $app): bool {
128
		$path = explode('/', $root . '/' . $file);
129
130
		$fileNameSCSS = array_pop($path);
131
		$fileNameCSS = $this->prependVersionPrefix($this->prependBaseurlPrefix(str_replace('.scss', '.css', $fileNameSCSS)), $app);
132
133
		$path = implode('/', $path);
134
		$webDir = $this->getWebDir($path, $app, $this->serverRoot, \OC::$WEBROOT);
135
136
		$this->logger->debug('SCSSCacher::process ordinary check follows', ['app' => 'scss_cacher']);
137
138
		try {
139
			$folder = $this->appData->getFolder($app);
140
		} catch (NotFoundException $e) {
141
			// creating css appdata folder
142
			$folder = $this->appData->newFolder($app);
143
		}
144
145
		$lockKey = $webDir . '/' . $fileNameSCSS;
146
147
		if (!$this->lockingCache->add($lockKey, 'locked!', 120)) {
148
			$this->logger->debug('SCSSCacher::process could not get lock for ' . $lockKey . ' and will wait 10 seconds for cached file to be available', ['app' => 'scss_cacher']);
149
			$retry = 0;
150
			sleep(1);
151
			while ($retry < 10) {
152
				$this->appConfig->clearCachedConfig();
153
				$this->logger->debug('SCSSCacher::process check in while loop follows', ['app' => 'scss_cacher']);
154
				if (!$this->variablesChanged() && $this->isCached($fileNameCSS, $app)) {
155
					// Inject icons vars css if any
156
					$this->logger->debug("SCSSCacher::process cached file for app '$app' and file '$fileNameCSS' is now available after $retry s. Moving on...", ['app' => 'scss_cacher']);
157
					return true;
158
				}
159
				sleep(1);
160
				$retry++;
161
			}
162
			$this->logger->debug('SCSSCacher::process Giving up scss caching for ' . $lockKey, ['app' => 'scss_cacher']);
163
			return false;
164
		}
165
166
		$this->logger->debug('SCSSCacher::process Lock acquired for ' . $lockKey, ['app' => 'scss_cacher']);
167
		try {
168
			$cached = $this->cache($path, $fileNameCSS, $fileNameSCSS, $folder, $webDir);
169
		} catch (\Exception $e) {
170
			$this->lockingCache->remove($lockKey);
171
			throw $e;
172
		}
173
174
		// Cleaning lock
175
		$this->lockingCache->remove($lockKey);
176
		$this->logger->debug('SCSSCacher::process Lock removed for ' . $lockKey, ['app' => 'scss_cacher']);
177
178
		return $cached;
179
	}
180
181
	/**
182
	 * @param $appName
183
	 * @param $fileName
184
	 * @return ISimpleFile
185
	 */
186
	public function getCachedCSS(string $appName, string $fileName): ISimpleFile {
187
		$folder = $this->appData->getFolder($appName);
188
		$cachedFileName = $this->prependVersionPrefix($this->prependBaseurlPrefix($fileName), $appName);
189
190
		return $folder->getFile($cachedFileName);
191
	}
192
193
	/**
194
	 * Check if the file is cached or not
195
	 * @param string $fileNameCSS
196
	 * @param string $app
197
	 * @return boolean
198
	 */
199
	private function isCached(string $fileNameCSS, string $app) {
200
		$key = $this->config->getSystemValue('version') . '/' . $app . '/' . $fileNameCSS;
201
202
		// If the file mtime is more recent than our cached one,
203
		// let's consider the file is properly cached
204
		if ($cacheValue = $this->isCachedCache->get($key)) {
205
			if ($cacheValue > $this->timeFactory->getTime()) {
206
				return true;
207
			}
208
		}
209
		$this->logger->debug("SCSSCacher::isCached $fileNameCSS isCachedCache is expired or unset", ['app' => 'scss_cacher']);
210
211
		// Creating file cache if none for further checks
212
		try {
213
			$folder = $this->appData->getFolder($app);
214
		} catch (NotFoundException $e) {
215
			$this->logger->debug("SCSSCacher::isCached app data folder for $app could not be fetched", ['app' => 'scss_cacher']);
216
			return false;
217
		}
218
219
		// Checking if file size is coherent
220
		// and if one of the css dependency changed
221
		try {
222
			$cachedFile = $folder->getFile($fileNameCSS);
223
			if ($cachedFile->getSize() > 0) {
224
				$depFileName = $fileNameCSS . '.deps';
225
				$deps = $this->depsCache->get($folder->getName() . '-' . $depFileName);
226
				if ($deps === null) {
227
					$depFile = $folder->getFile($depFileName);
228
					$deps = $depFile->getContent();
229
					// Set to memcache for next run
230
					$this->depsCache->set($folder->getName() . '-' . $depFileName, $deps);
231
				}
232
				$deps = json_decode($deps, true);
233
234
				foreach ((array) $deps as $file => $mtime) {
235
					if (!file_exists($file) || filemtime($file) > $mtime) {
236
						$this->logger->debug("SCSSCacher::isCached $fileNameCSS is not considered as cached due to deps file $file", ['app' => 'scss_cacher']);
237
						return false;
238
					}
239
				}
240
241
				$this->logger->debug("SCSSCacher::isCached $fileNameCSS dependencies successfully cached for 5 minutes", ['app' => 'scss_cacher']);
242
				// It would probably make sense to adjust this timeout to something higher and see if that has some effect then
243
				$this->isCachedCache->set($key, $this->timeFactory->getTime() + 5 * 60);
244
				return true;
245
			}
246
			$this->logger->debug("SCSSCacher::isCached $fileNameCSS is not considered as cached cacheValue: $cacheValue", ['app' => 'scss_cacher']);
247
			return false;
248
		} catch (NotFoundException $e) {
249
			$this->logger->debug("SCSSCacher::isCached NotFoundException " . $e->getMessage(), ['app' => 'scss_cacher']);
250
			return false;
251
		}
252
	}
253
254
	/**
255
	 * Check if the variables file has changed
256
	 * @return bool
257
	 */
258
	private function variablesChanged(): bool {
259
		$cachedVariables = $this->config->getAppValue('core', 'theming.variables', '');
260
		$injectedVariables = $this->getInjectedVariables($cachedVariables);
261
		if ($cachedVariables !== md5($injectedVariables)) {
262
			$this->logger->debug('SCSSCacher::variablesChanged storedVariables: ' . json_encode($this->config->getAppValue('core', 'theming.variables')) . ' currentInjectedVariables: ' . json_encode($injectedVariables), ['app' => 'scss_cacher']);
263
			$this->config->setAppValue('core', 'theming.variables', md5($injectedVariables));
264
			$this->resetCache();
265
			return true;
266
		}
267
		return false;
268
	}
269
270
	/**
271
	 * Cache the file with AppData
272
	 *
273
	 * @param string $path
274
	 * @param string $fileNameCSS
275
	 * @param string $fileNameSCSS
276
	 * @param ISimpleFolder $folder
277
	 * @param string $webDir
278
	 * @return boolean
279
	 * @throws NotPermittedException
280
	 */
281
	private function cache(string $path, string $fileNameCSS, string $fileNameSCSS, ISimpleFolder $folder, string $webDir) {
282
		$scss = new Compiler();
283
		$scss->setImportPaths([
284
			$path,
285
			$this->serverRoot . '/core/css/'
286
		]);
287
288
		// Continue after throw
289
		if ($this->config->getSystemValue('debug')) {
290
			// Debug mode
291
			$scss->setOutputStyle(OutputStyle::EXPANDED);
292
		} else {
293
			// Compression
294
			$scss->setOutputStyle(OutputStyle::COMPRESSED);
295
		}
296
297
		try {
298
			$cachedfile = $folder->getFile($fileNameCSS);
299
		} catch (NotFoundException $e) {
300
			$cachedfile = $folder->newFile($fileNameCSS);
301
		}
302
303
		$depFileName = $fileNameCSS . '.deps';
304
		try {
305
			$depFile = $folder->getFile($depFileName);
306
		} catch (NotFoundException $e) {
307
			$depFile = $folder->newFile($depFileName);
308
		}
309
310
		// Compile
311
		try {
312
			$compiledScss = $scss->compile(
313
				'$webroot: \'' . $this->getRoutePrefix() . '\';' .
314
				$this->getInjectedVariables() .
315
				'@import "variables.scss";' .
316
				'@import "functions.scss";' .
317
				'@import "' . $fileNameSCSS . '";');
318
		} catch (\Exception $e) {
319
			$this->logger->error($e->getMessage(), ['app' => 'scss_cacher', 'exception' => $e]);
320
321
			return false;
322
		}
323
324
		// Gzip file
325
		try {
326
			$gzipFile = $folder->getFile($fileNameCSS . '.gzip'); # Safari doesn't like .gz
327
		} catch (NotFoundException $e) {
328
			$gzipFile = $folder->newFile($fileNameCSS . '.gzip'); # Safari doesn't like .gz
329
		}
330
331
		try {
332
			$data = $this->rebaseUrls($compiledScss, $webDir);
333
			$cachedfile->putContent($data);
334
			$deps = json_encode($scss->getParsedFiles());
0 ignored issues
show
Deprecated Code introduced by
The function ScssPhp\ScssPhp\Compiler::getParsedFiles() has been deprecated. ( Ignorable by Annotation )

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

334
			$deps = json_encode(/** @scrutinizer ignore-deprecated */ $scss->getParsedFiles());
Loading history...
335
			$depFile->putContent($deps);
336
			$this->depsCache->set($folder->getName() . '-' . $depFileName, $deps);
337
			$gzipFile->putContent(gzencode($data, 9));
338
			$this->logger->debug('SCSSCacher::cache ' . $webDir . '/' . $fileNameSCSS . ' compiled and successfully cached', ['app' => 'scss_cacher']);
339
340
			return true;
341
		} catch (NotPermittedException $e) {
342
			$this->logger->error('SCSSCacher::cache unable to cache: ' . $fileNameSCSS, ['app' => 'scss_cacher']);
343
344
			return false;
345
		}
346
	}
347
348
	/**
349
	 * Reset scss cache by deleting all generated css files
350
	 * We need to regenerate all files when variables change
351
	 */
352
	public function resetCache() {
353
		$this->logger->debug('SCSSCacher::resetCache', ['app' => 'scss_cacher']);
354
		if (!$this->lockingCache->add('resetCache', 'locked!', 120)) {
355
			$this->logger->debug('SCSSCacher::resetCache Locked', ['app' => 'scss_cacher']);
356
			return;
357
		}
358
		$this->logger->debug('SCSSCacher::resetCache Lock acquired', ['app' => 'scss_cacher']);
359
		$this->injectedVariables = null;
360
361
		// do not clear locks
362
		$this->depsCache->clear();
363
		$this->isCachedCache->clear();
364
365
		$appDirectory = $this->appData->getDirectoryListing();
366
		foreach ($appDirectory as $folder) {
367
			foreach ($folder->getDirectoryListing() as $file) {
368
				try {
369
					$file->delete();
370
				} catch (NotPermittedException $e) {
371
					$this->logger->error('SCSSCacher::resetCache unable to delete file: ' . $file->getName(), ['exception' => $e, 'app' => 'scss_cacher']);
372
				}
373
			}
374
		}
375
		$this->logger->debug('SCSSCacher::resetCache css cache cleared!', ['app' => 'scss_cacher']);
376
		$this->lockingCache->remove('resetCache');
377
		$this->logger->debug('SCSSCacher::resetCache Locking removed', ['app' => 'scss_cacher']);
378
	}
379
380
	/**
381
	 * @return string SCSS code for variables from OC_Defaults
382
	 */
383
	private function getInjectedVariables(string $cache = ''): string {
384
		if ($this->injectedVariables !== null) {
385
			return $this->injectedVariables;
386
		}
387
		$variables = '';
388
		foreach ($this->defaults->getScssVariables() as $key => $value) {
389
			$variables .= '$' . $key . ': ' . $value . ' !default;';
390
		}
391
392
		/*
393
		 * If we are trying to return the same variables as that are cached
394
		 * Then there is no need to do the compile step
395
		 */
396
		if ($cache === md5($variables)) {
397
			$this->injectedVariables = $variables;
398
			return $variables;
399
		}
400
401
		// check for valid variables / otherwise fall back to defaults
402
		try {
403
			$scss = new Compiler();
404
			$scss->compile($variables);
405
			$this->injectedVariables = $variables;
406
		} catch (\Exception $e) {
407
			$this->logger->error($e->getMessage(), ['exception' => $e, 'app' => 'scss_cacher']);
408
		}
409
410
		return $variables;
411
	}
412
413
	/**
414
	 * Add the correct uri prefix to make uri valid again
415
	 * @param string $css
416
	 * @param string $webDir
417
	 * @return string
418
	 */
419
	private function rebaseUrls(string $css, string $webDir): string {
420
		$re = '/url\([\'"]([^\/][\.\w?=\/-]*)[\'"]\)/x';
421
		$subst = 'url(\'' . $webDir . '/$1\')';
422
423
		return preg_replace($re, $subst, $css);
424
	}
425
426
	/**
427
	 * Return the cached css file uri
428
	 * @param string $appName the app name
429
	 * @param string $fileName
430
	 * @return string
431
	 */
432
	public function getCachedSCSS(string $appName, string $fileName): string {
433
		$tmpfileLoc = explode('/', $fileName);
434
		$fileName = array_pop($tmpfileLoc);
435
		$fileName = $this->prependVersionPrefix($this->prependBaseurlPrefix(str_replace('.scss', '.css', $fileName)), $appName);
436
437
		return substr($this->urlGenerator->linkToRoute('core.Css.getCss', [
438
			'fileName' => $fileName,
439
			'appName' => $appName,
440
			'v' => $this->config->getAppValue('core', 'theming.variables', '0')
441
		]), \strlen(\OC::$WEBROOT) + 1);
442
	}
443
444
	/**
445
	 * Prepend hashed base url to the css file
446
	 * @param string $cssFile
447
	 * @return string
448
	 */
449
	private function prependBaseurlPrefix(string $cssFile): string {
450
		return substr(md5($this->urlGenerator->getBaseUrl() . $this->getRoutePrefix()), 0, 4) . '-' . $cssFile;
451
	}
452
453
	private function getRoutePrefix() {
454
		$frontControllerActive = ($this->config->getSystemValue('htaccess.IgnoreFrontController', false) === true || getenv('front_controller_active') === 'true');
455
		$prefix = \OC::$WEBROOT . '/index.php';
456
		if ($frontControllerActive) {
457
			$prefix = \OC::$WEBROOT;
458
		}
459
		return $prefix;
460
	}
461
462
	/**
463
	 * Prepend hashed app version hash
464
	 * @param string $cssFile
465
	 * @param string $appId
466
	 * @return string
467
	 */
468
	private function prependVersionPrefix(string $cssFile, string $appId): string {
469
		$appVersion = \OC_App::getAppVersion($appId);
470
		if ($appVersion !== '0') {
471
			return substr(md5($appVersion), 0, 4) . '-' . $cssFile;
472
		}
473
		$coreVersion = \OC_Util::getVersionString();
474
475
		return substr(md5($coreVersion), 0, 4) . '-' . $cssFile;
476
	}
477
478
	/**
479
	 * Get WebDir root
480
	 * @param string $path the css file path
481
	 * @param string $appName the app name
482
	 * @param string $serverRoot the server root path
483
	 * @param string $webRoot the nextcloud installation root path
484
	 * @return string the webDir
485
	 */
486
	private function getWebDir(string $path, string $appName, string $serverRoot, string $webRoot): string {
487
		// Detect if path is within server root AND if path is within an app path
488
		if (strpos($path, $serverRoot) === false && $appWebPath = \OC_App::getAppWebPath($appName)) {
489
			// Get the file path within the app directory
490
			$appDirectoryPath = explode($appName, $path)[1];
491
			// Remove the webroot
492
493
			return str_replace($webRoot, '', $appWebPath . $appDirectoryPath);
494
		}
495
496
		return $webRoot . substr($path, strlen($serverRoot));
497
	}
498
}
499