You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

533 lines
17 KiB

3 years ago
  1. <?php
  2. /**
  3. * @copyright Copyright (c) 2016, John Molakvoæ (skjnldsv@protonmail.com)
  4. *
  5. * @author Christoph Wurst <christoph@winzerhof-wurst.at>
  6. * @author John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
  7. * @author Julius Haertl <jus@bitgrid.net>
  8. * @author Julius Härtl <jus@bitgrid.net>
  9. * @author Morris Jobke <hey@morrisjobke.de>
  10. * @author Robin Appelman <robin@icewind.nl>
  11. * @author Roeland Jago Douma <roeland@famdouma.nl>
  12. * @author Roland Tapken <roland@bitarbeiter.net>
  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. use OC\AppConfig;
  32. use OC\Files\AppData\Factory;
  33. use OC\Memcache\NullCache;
  34. use OCP\AppFramework\Utility\ITimeFactory;
  35. use OCP\Files\IAppData;
  36. use OCP\Files\NotFoundException;
  37. use OCP\Files\NotPermittedException;
  38. use OCP\Files\SimpleFS\ISimpleFile;
  39. use OCP\Files\SimpleFS\ISimpleFolder;
  40. use OCP\ICache;
  41. use OCP\ICacheFactory;
  42. use OCP\IConfig;
  43. use OCP\ILogger;
  44. use OCP\IMemcache;
  45. use OCP\IURLGenerator;
  46. use ScssPhp\ScssPhp\Compiler;
  47. use ScssPhp\ScssPhp\Exception\ParserException;
  48. use ScssPhp\ScssPhp\Formatter\Crunched;
  49. use ScssPhp\ScssPhp\Formatter\Expanded;
  50. class SCSSCacher {
  51. /** @var ILogger */
  52. protected $logger;
  53. /** @var IAppData */
  54. protected $appData;
  55. /** @var IURLGenerator */
  56. protected $urlGenerator;
  57. /** @var IConfig */
  58. protected $config;
  59. /** @var \OC_Defaults */
  60. private $defaults;
  61. /** @var string */
  62. protected $serverRoot;
  63. /** @var ICache */
  64. protected $depsCache;
  65. /** @var null|string */
  66. private $injectedVariables;
  67. /** @var ICacheFactory */
  68. private $cacheFactory;
  69. /** @var IconsCacher */
  70. private $iconsCacher;
  71. /** @var ICache */
  72. private $isCachedCache;
  73. /** @var ITimeFactory */
  74. private $timeFactory;
  75. /** @var IMemcache */
  76. private $lockingCache;
  77. /** @var AppConfig */
  78. private $appConfig;
  79. /**
  80. * @param ILogger $logger
  81. * @param Factory $appDataFactory
  82. * @param IURLGenerator $urlGenerator
  83. * @param IConfig $config
  84. * @param \OC_Defaults $defaults
  85. * @param string $serverRoot
  86. * @param ICacheFactory $cacheFactory
  87. * @param IconsCacher $iconsCacher
  88. * @param ITimeFactory $timeFactory
  89. */
  90. public function __construct(ILogger $logger,
  91. Factory $appDataFactory,
  92. IURLGenerator $urlGenerator,
  93. IConfig $config,
  94. \OC_Defaults $defaults,
  95. $serverRoot,
  96. ICacheFactory $cacheFactory,
  97. IconsCacher $iconsCacher,
  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->iconsCacher = $iconsCacher;
  115. $this->timeFactory = $timeFactory;
  116. $this->appConfig = $appConfig;
  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. $fileNameSCSS = array_pop($path);
  130. $fileNameCSS = $this->prependVersionPrefix($this->prependBaseurlPrefix(str_replace('.scss', '.css', $fileNameSCSS)), $app);
  131. $path = implode('/', $path);
  132. $webDir = $this->getWebDir($path, $app, $this->serverRoot, \OC::$WEBROOT);
  133. $this->logger->debug('SCSSCacher::process ordinary check follows', ['app' => 'scss_cacher']);
  134. if (!$this->variablesChanged() && $this->isCached($fileNameCSS, $app)) {
  135. // Inject icons vars css if any
  136. return $this->injectCssVariablesIfAny();
  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. $lockKey = $webDir . '/' . $fileNameSCSS;
  145. if (!$this->lockingCache->add($lockKey, 'locked!', 120)) {
  146. $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']);
  147. $retry = 0;
  148. sleep(1);
  149. while ($retry < 10) {
  150. $this->appConfig->clearCachedConfig();
  151. $this->logger->debug('SCSSCacher::process check in while loop follows', ['app' => 'scss_cacher']);
  152. if (!$this->variablesChanged() && $this->isCached($fileNameCSS, $app)) {
  153. // Inject icons vars css if any
  154. $this->logger->debug("SCSSCacher::process cached file for app '$app' and file '$fileNameCSS' is now available after $retry s. Moving on...", ['app' => 'scss_cacher']);
  155. return $this->injectCssVariablesIfAny();
  156. }
  157. sleep(1);
  158. $retry++;
  159. }
  160. $this->logger->debug('SCSSCacher::process Giving up scss caching for ' . $lockKey, ['app' => 'scss_cacher']);
  161. return false;
  162. }
  163. $this->logger->debug('SCSSCacher::process Lock acquired for ' . $lockKey, ['app' => 'scss_cacher']);
  164. try {
  165. $cached = $this->cache($path, $fileNameCSS, $fileNameSCSS, $folder, $webDir);
  166. } catch (\Exception $e) {
  167. $this->lockingCache->remove($lockKey);
  168. throw $e;
  169. }
  170. // Cleaning lock
  171. $this->lockingCache->remove($lockKey);
  172. $this->logger->debug('SCSSCacher::process Lock removed for ' . $lockKey, ['app' => 'scss_cacher']);
  173. // Inject icons vars css if any
  174. if ($this->iconsCacher->getCachedCSS() && $this->iconsCacher->getCachedCSS()->getSize() > 0) {
  175. $this->iconsCacher->injectCss();
  176. }
  177. return $cached;
  178. }
  179. /**
  180. * @param $appName
  181. * @param $fileName
  182. * @return ISimpleFile
  183. */
  184. public function getCachedCSS(string $appName, string $fileName): ISimpleFile {
  185. $folder = $this->appData->getFolder($appName);
  186. $cachedFileName = $this->prependVersionPrefix($this->prependBaseurlPrefix($fileName), $appName);
  187. return $folder->getFile($cachedFileName);
  188. }
  189. /**
  190. * Check if the file is cached or not
  191. * @param string $fileNameCSS
  192. * @param string $app
  193. * @return boolean
  194. */
  195. private function isCached(string $fileNameCSS, string $app) {
  196. $key = $this->config->getSystemValue('version') . '/' . $app . '/' . $fileNameCSS;
  197. // If the file mtime is more recent than our cached one,
  198. // let's consider the file is properly cached
  199. if ($cacheValue = $this->isCachedCache->get($key)) {
  200. if ($cacheValue > $this->timeFactory->getTime()) {
  201. return true;
  202. }
  203. }
  204. $this->logger->debug("SCSSCacher::isCached $fileNameCSS isCachedCache is expired or unset", ['app' => 'scss_cacher']);
  205. // Creating file cache if none for further checks
  206. try {
  207. $folder = $this->appData->getFolder($app);
  208. } catch (NotFoundException $e) {
  209. $this->logger->debug("SCSSCacher::isCached app data folder for $app could not be fetched", ['app' => 'scss_cacher']);
  210. return false;
  211. }
  212. // Checking if file size is coherent
  213. // and if one of the css dependency changed
  214. try {
  215. $cachedFile = $folder->getFile($fileNameCSS);
  216. if ($cachedFile->getSize() > 0) {
  217. $depFileName = $fileNameCSS . '.deps';
  218. $deps = $this->depsCache->get($folder->getName() . '-' . $depFileName);
  219. if ($deps === null) {
  220. $depFile = $folder->getFile($depFileName);
  221. $deps = $depFile->getContent();
  222. // Set to memcache for next run
  223. $this->depsCache->set($folder->getName() . '-' . $depFileName, $deps);
  224. }
  225. $deps = json_decode($deps, true);
  226. foreach ((array) $deps as $file => $mtime) {
  227. if (!file_exists($file) || filemtime($file) > $mtime) {
  228. $this->logger->debug("SCSSCacher::isCached $fileNameCSS is not considered as cached due to deps file $file", ['app' => 'scss_cacher']);
  229. return false;
  230. }
  231. }
  232. $this->logger->debug("SCSSCacher::isCached $fileNameCSS dependencies successfully cached for 5 minutes", ['app' => 'scss_cacher']);
  233. // It would probably make sense to adjust this timeout to something higher and see if that has some effect then
  234. $this->isCachedCache->set($key, $this->timeFactory->getTime() + 5 * 60);
  235. return true;
  236. }
  237. $this->logger->debug("SCSSCacher::isCached $fileNameCSS is not considered as cached cacheValue: $cacheValue", ['app' => 'scss_cacher']);
  238. return false;
  239. } catch (NotFoundException $e) {
  240. $this->logger->debug("SCSSCacher::isCached NotFoundException " . $e->getMessage(), ['app' => 'scss_cacher']);
  241. return false;
  242. }
  243. }
  244. /**
  245. * Check if the variables file has changed
  246. * @return bool
  247. */
  248. private function variablesChanged(): bool {
  249. $injectedVariables = $this->getInjectedVariables();
  250. if ($this->config->getAppValue('core', 'theming.variables') !== md5($injectedVariables)) {
  251. $this->logger->debug('SCSSCacher::variablesChanged storedVariables: ' . json_encode($this->config->getAppValue('core', 'theming.variables')) . ' currentInjectedVariables: ' . json_encode($injectedVariables), ['app' => 'scss_cacher']);
  252. $this->config->setAppValue('core', 'theming.variables', md5($injectedVariables));
  253. $this->resetCache();
  254. return true;
  255. }
  256. return false;
  257. }
  258. /**
  259. * Cache the file with AppData
  260. *
  261. * @param string $path
  262. * @param string $fileNameCSS
  263. * @param string $fileNameSCSS
  264. * @param ISimpleFolder $folder
  265. * @param string $webDir
  266. * @return boolean
  267. * @throws NotPermittedException
  268. */
  269. private function cache(string $path, string $fileNameCSS, string $fileNameSCSS, ISimpleFolder $folder, string $webDir) {
  270. $scss = new Compiler();
  271. $scss->setImportPaths([
  272. $path,
  273. $this->serverRoot . '/core/css/'
  274. ]);
  275. // Continue after throw
  276. $scss->setIgnoreErrors(true);
  277. if ($this->config->getSystemValue('debug')) {
  278. // Debug mode
  279. $scss->setFormatter(Expanded::class);
  280. $scss->setLineNumberStyle(Compiler::LINE_COMMENTS);
  281. } else {
  282. // Compression
  283. $scss->setFormatter(Crunched::class);
  284. }
  285. try {
  286. $cachedfile = $folder->getFile($fileNameCSS);
  287. } catch (NotFoundException $e) {
  288. $cachedfile = $folder->newFile($fileNameCSS);
  289. }
  290. $depFileName = $fileNameCSS . '.deps';
  291. try {
  292. $depFile = $folder->getFile($depFileName);
  293. } catch (NotFoundException $e) {
  294. $depFile = $folder->newFile($depFileName);
  295. }
  296. // Compile
  297. try {
  298. $compiledScss = $scss->compile(
  299. '$webroot: \'' . $this->getRoutePrefix() . '\';' .
  300. $this->getInjectedVariables() .
  301. '@import "variables.scss";' .
  302. '@import "functions.scss";' .
  303. '@import "' . $fileNameSCSS . '";');
  304. } catch (ParserException $e) {
  305. $this->logger->logException($e, ['app' => 'scss_cacher']);
  306. return false;
  307. }
  308. // Parse Icons and create related css variables
  309. $compiledScss = $this->iconsCacher->setIconsCss($compiledScss);
  310. // Gzip file
  311. try {
  312. $gzipFile = $folder->getFile($fileNameCSS . '.gzip'); # Safari doesn't like .gz
  313. } catch (NotFoundException $e) {
  314. $gzipFile = $folder->newFile($fileNameCSS . '.gzip'); # Safari doesn't like .gz
  315. }
  316. try {
  317. $data = $this->rebaseUrls($compiledScss, $webDir);
  318. $cachedfile->putContent($data);
  319. $deps = json_encode($scss->getParsedFiles());
  320. $depFile->putContent($deps);
  321. $this->depsCache->set($folder->getName() . '-' . $depFileName, $deps);
  322. $gzipFile->putContent(gzencode($data, 9));
  323. $this->logger->debug('SCSSCacher::cache ' . $webDir . '/' . $fileNameSCSS . ' compiled and successfully cached', ['app' => 'scss_cacher']);
  324. return true;
  325. } catch (NotPermittedException $e) {
  326. $this->logger->error('SCSSCacher::cache unable to cache: ' . $fileNameSCSS, ['app' => 'scss_cacher']);
  327. return false;
  328. }
  329. }
  330. /**
  331. * Reset scss cache by deleting all generated css files
  332. * We need to regenerate all files when variables change
  333. */
  334. public function resetCache() {
  335. $this->logger->debug('SCSSCacher::resetCache', ['app' => 'scss_cacher']);
  336. if (!$this->lockingCache->add('resetCache', 'locked!', 120)) {
  337. $this->logger->debug('SCSSCacher::resetCache Locked', ['app' => 'scss_cacher']);
  338. return;
  339. }
  340. $this->logger->debug('SCSSCacher::resetCache Lock acquired', ['app' => 'scss_cacher']);
  341. $this->injectedVariables = null;
  342. // do not clear locks
  343. $this->cacheFactory->createDistributed('SCSS-deps-')->clear();
  344. $this->cacheFactory->createDistributed('SCSS-cached-')->clear();
  345. $appDirectory = $this->appData->getDirectoryListing();
  346. foreach ($appDirectory as $folder) {
  347. foreach ($folder->getDirectoryListing() as $file) {
  348. try {
  349. $file->delete();
  350. } catch (NotPermittedException $e) {
  351. $this->logger->logException($e, ['message' => 'SCSSCacher::resetCache unable to delete file: ' . $file->getName(), 'app' => 'scss_cacher']);
  352. }
  353. }
  354. }
  355. $this->logger->debug('SCSSCacher::resetCache css cache cleared!', ['app' => 'scss_cacher']);
  356. $this->lockingCache->remove('resetCache');
  357. $this->logger->debug('SCSSCacher::resetCache Locking removed', ['app' => 'scss_cacher']);
  358. }
  359. /**
  360. * @return string SCSS code for variables from OC_Defaults
  361. */
  362. private function getInjectedVariables(): string {
  363. if ($this->injectedVariables !== null) {
  364. return $this->injectedVariables;
  365. }
  366. $variables = '';
  367. foreach ($this->defaults->getScssVariables() as $key => $value) {
  368. $variables .= '$' . $key . ': ' . $value . ' !default;';
  369. }
  370. // check for valid variables / otherwise fall back to defaults
  371. try {
  372. $scss = new Compiler();
  373. $scss->compile($variables);
  374. $this->injectedVariables = $variables;
  375. } catch (ParserException $e) {
  376. $this->logger->logException($e, ['app' => 'scss_cacher']);
  377. }
  378. return $variables;
  379. }
  380. /**
  381. * Add the correct uri prefix to make uri valid again
  382. * @param string $css
  383. * @param string $webDir
  384. * @return string
  385. */
  386. private function rebaseUrls(string $css, string $webDir): string {
  387. $re = '/url\([\'"]([^\/][\.\w?=\/-]*)[\'"]\)/x';
  388. $subst = 'url(\'' . $webDir . '/$1\')';
  389. return preg_replace($re, $subst, $css);
  390. }
  391. /**
  392. * Return the cached css file uri
  393. * @param string $appName the app name
  394. * @param string $fileName
  395. * @return string
  396. */
  397. public function getCachedSCSS(string $appName, string $fileName): string {
  398. $tmpfileLoc = explode('/', $fileName);
  399. $fileName = array_pop($tmpfileLoc);
  400. $fileName = $this->prependVersionPrefix($this->prependBaseurlPrefix(str_replace('.scss', '.css', $fileName)), $appName);
  401. return substr($this->urlGenerator->linkToRoute('core.Css.getCss', [
  402. 'fileName' => $fileName,
  403. 'appName' => $appName,
  404. 'v' => $this->config->getAppValue('core', 'theming.variables', '0')
  405. ]), \strlen(\OC::$WEBROOT) + 1);
  406. }
  407. /**
  408. * Prepend hashed base url to the css file
  409. * @param string $cssFile
  410. * @return string
  411. */
  412. private function prependBaseurlPrefix(string $cssFile): string {
  413. return substr(md5($this->urlGenerator->getBaseUrl() . $this->getRoutePrefix()), 0, 4) . '-' . $cssFile;
  414. }
  415. private function getRoutePrefix() {
  416. $frontControllerActive = ($this->config->getSystemValue('htaccess.IgnoreFrontController', false) === true || getenv('front_controller_active') === 'true');
  417. $prefix = \OC::$WEBROOT . '/index.php';
  418. if ($frontControllerActive) {
  419. $prefix = \OC::$WEBROOT;
  420. }
  421. return $prefix;
  422. }
  423. /**
  424. * Prepend hashed app version hash
  425. * @param string $cssFile
  426. * @param string $appId
  427. * @return string
  428. */
  429. private function prependVersionPrefix(string $cssFile, string $appId): string {
  430. $appVersion = \OC_App::getAppVersion($appId);
  431. if ($appVersion !== '0') {
  432. return substr(md5($appVersion), 0, 4) . '-' . $cssFile;
  433. }
  434. $coreVersion = \OC_Util::getVersionString();
  435. return substr(md5($coreVersion), 0, 4) . '-' . $cssFile;
  436. }
  437. /**
  438. * Get WebDir root
  439. * @param string $path the css file path
  440. * @param string $appName the app name
  441. * @param string $serverRoot the server root path
  442. * @param string $webRoot the nextcloud installation root path
  443. * @return string the webDir
  444. */
  445. private function getWebDir(string $path, string $appName, string $serverRoot, string $webRoot): string {
  446. // Detect if path is within server root AND if path is within an app path
  447. if (strpos($path, $serverRoot) === false && $appWebPath = \OC_App::getAppWebPath($appName)) {
  448. // Get the file path within the app directory
  449. $appDirectoryPath = explode($appName, $path)[1];
  450. // Remove the webroot
  451. return str_replace($webRoot, '', $appWebPath . $appDirectoryPath);
  452. }
  453. return $webRoot . substr($path, strlen($serverRoot));
  454. }
  455. /**
  456. * Add the icons css cache in the header if needed
  457. *
  458. * @return boolean true
  459. */
  460. private function injectCssVariablesIfAny() {
  461. // Inject icons vars css if any
  462. if ($this->iconsCacher->getCachedCSS() && $this->iconsCacher->getCachedCSS()->getSize() > 0) {
  463. $this->iconsCacher->injectCss();
  464. }
  465. return true;
  466. }
  467. }