SCSSCacher.php 15 KB

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