SCSSCacher.php 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  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\Files\AppData\Factory;
  34. use OCP\Files\IAppData;
  35. use OCP\Files\NotFoundException;
  36. use OCP\Files\NotPermittedException;
  37. use OCP\Files\SimpleFS\ISimpleFile;
  38. use OCP\Files\SimpleFS\ISimpleFolder;
  39. use OCP\ICache;
  40. use OCP\ICacheFactory;
  41. use OCP\IConfig;
  42. use OCP\ILogger;
  43. use OCP\IURLGenerator;
  44. class SCSSCacher {
  45. /** @var ILogger */
  46. protected $logger;
  47. /** @var IAppData */
  48. protected $appData;
  49. /** @var IURLGenerator */
  50. protected $urlGenerator;
  51. /** @var IConfig */
  52. protected $config;
  53. /** @var \OC_Defaults */
  54. private $defaults;
  55. /** @var string */
  56. protected $serverRoot;
  57. /** @var ICache */
  58. protected $depsCache;
  59. /** @var null|string */
  60. private $injectedVariables;
  61. /** @var ICacheFactory */
  62. private $cacheFactory;
  63. /**
  64. * @param ILogger $logger
  65. * @param Factory $appDataFactory
  66. * @param IURLGenerator $urlGenerator
  67. * @param IConfig $config
  68. * @param \OC_Defaults $defaults
  69. * @param string $serverRoot
  70. * @param ICacheFactory $cacheFactory
  71. */
  72. public function __construct(ILogger $logger,
  73. Factory $appDataFactory,
  74. IURLGenerator $urlGenerator,
  75. IConfig $config,
  76. \OC_Defaults $defaults,
  77. $serverRoot,
  78. ICacheFactory $cacheFactory) {
  79. $this->logger = $logger;
  80. $this->appData = $appDataFactory->get('css');
  81. $this->urlGenerator = $urlGenerator;
  82. $this->config = $config;
  83. $this->defaults = $defaults;
  84. $this->serverRoot = $serverRoot;
  85. $this->cacheFactory = $cacheFactory;
  86. $this->depsCache = $cacheFactory->createDistributed('SCSS-' . md5($this->urlGenerator->getBaseUrl()));
  87. }
  88. /**
  89. * Process the caching process if needed
  90. *
  91. * @param string $root Root path to the nextcloud installation
  92. * @param string $file
  93. * @param string $app The app name
  94. * @return boolean
  95. * @throws NotPermittedException
  96. */
  97. public function process(string $root, string $file, string $app): bool {
  98. $path = explode('/', $root . '/' . $file);
  99. $fileNameSCSS = array_pop($path);
  100. $fileNameCSS = $this->prependBaseurlPrefix(str_replace('.scss', '.css', $fileNameSCSS));
  101. $path = implode('/', $path);
  102. $webDir = $this->getWebDir($path, $app, $this->serverRoot, \OC::$WEBROOT);
  103. try {
  104. $folder = $this->appData->getFolder($app);
  105. } catch(NotFoundException $e) {
  106. // creating css appdata folder
  107. $folder = $this->appData->newFolder($app);
  108. }
  109. if(!$this->variablesChanged() && $this->isCached($fileNameCSS, $folder)) {
  110. return true;
  111. }
  112. return $this->cache($path, $fileNameCSS, $fileNameSCSS, $folder, $webDir);
  113. }
  114. /**
  115. * @param $appName
  116. * @param $fileName
  117. * @return ISimpleFile
  118. */
  119. public function getCachedCSS(string $appName, string $fileName): ISimpleFile {
  120. $folder = $this->appData->getFolder($appName);
  121. return $folder->getFile($this->prependBaseurlPrefix($fileName));
  122. }
  123. /**
  124. * Check if the file is cached or not
  125. * @param string $fileNameCSS
  126. * @param ISimpleFolder $folder
  127. * @return boolean
  128. */
  129. private function isCached(string $fileNameCSS, ISimpleFolder $folder) {
  130. try {
  131. $cachedFile = $folder->getFile($fileNameCSS);
  132. if ($cachedFile->getSize() > 0) {
  133. $depFileName = $fileNameCSS . '.deps';
  134. $deps = $this->depsCache->get($folder->getName() . '-' . $depFileName);
  135. if ($deps === null) {
  136. $depFile = $folder->getFile($depFileName);
  137. $deps = $depFile->getContent();
  138. //Set to memcache for next run
  139. $this->depsCache->set($folder->getName() . '-' . $depFileName, $deps);
  140. }
  141. $deps = json_decode($deps, true);
  142. foreach ((array)$deps as $file=>$mtime) {
  143. if (!file_exists($file) || filemtime($file) > $mtime) {
  144. return false;
  145. }
  146. }
  147. return true;
  148. }
  149. return false;
  150. } catch(NotFoundException $e) {
  151. return false;
  152. }
  153. }
  154. /**
  155. * Check if the variables file has changed
  156. * @return bool
  157. */
  158. private function variablesChanged(): bool {
  159. $injectedVariables = $this->getInjectedVariables();
  160. if($this->config->getAppValue('core', 'scss.variables') !== md5($injectedVariables)) {
  161. $this->resetCache();
  162. $this->config->setAppValue('core', 'scss.variables', md5($injectedVariables));
  163. return true;
  164. }
  165. return false;
  166. }
  167. /**
  168. * Cache the file with AppData
  169. *
  170. * @param string $path
  171. * @param string $fileNameCSS
  172. * @param string $fileNameSCSS
  173. * @param ISimpleFolder $folder
  174. * @param string $webDir
  175. * @return boolean
  176. * @throws NotPermittedException
  177. */
  178. private function cache(string $path, string $fileNameCSS, string $fileNameSCSS, ISimpleFolder $folder, string $webDir) {
  179. $scss = new Compiler();
  180. $scss->setImportPaths([
  181. $path,
  182. $this->serverRoot . '/core/css/',
  183. ]);
  184. // Continue after throw
  185. $scss->setIgnoreErrors(true);
  186. if($this->config->getSystemValue('debug')) {
  187. // Debug mode
  188. $scss->setFormatter(Expanded::class);
  189. $scss->setLineNumberStyle(Compiler::LINE_COMMENTS);
  190. } else {
  191. // Compression
  192. $scss->setFormatter(Crunched::class);
  193. }
  194. try {
  195. $cachedfile = $folder->getFile($fileNameCSS);
  196. } catch(NotFoundException $e) {
  197. $cachedfile = $folder->newFile($fileNameCSS);
  198. }
  199. $depFileName = $fileNameCSS . '.deps';
  200. try {
  201. $depFile = $folder->getFile($depFileName);
  202. } catch (NotFoundException $e) {
  203. $depFile = $folder->newFile($depFileName);
  204. }
  205. // Compile
  206. try {
  207. $compiledScss = $scss->compile(
  208. '@import "variables.scss";' .
  209. $this->getInjectedVariables() .
  210. '@import "'.$fileNameSCSS.'";');
  211. } catch(ParserException $e) {
  212. $this->logger->error($e, ['app' => 'core']);
  213. return false;
  214. }
  215. // Gzip file
  216. try {
  217. $gzipFile = $folder->getFile($fileNameCSS . '.gzip'); # Safari doesn't like .gz
  218. } catch (NotFoundException $e) {
  219. $gzipFile = $folder->newFile($fileNameCSS . '.gzip'); # Safari doesn't like .gz
  220. }
  221. try {
  222. $data = $this->rebaseUrls($compiledScss, $webDir);
  223. $cachedfile->putContent($data);
  224. $deps = json_encode($scss->getParsedFiles());
  225. $depFile->putContent($deps);
  226. $this->depsCache->set($folder->getName() . '-' . $depFileName, $deps);
  227. $gzipFile->putContent(gzencode($data, 9));
  228. $this->logger->debug('SCSSCacher: '.$webDir.'/'.$fileNameSCSS.' compiled and successfully cached', ['app' => 'core']);
  229. return true;
  230. } catch(NotPermittedException $e) {
  231. $this->logger->error('SCSSCacher: unable to cache: ' . $fileNameSCSS);
  232. return false;
  233. }
  234. }
  235. /**
  236. * Reset scss cache by deleting all generated css files
  237. * We need to regenerate all files when variables change
  238. */
  239. public function resetCache() {
  240. $this->injectedVariables = null;
  241. $this->cacheFactory->createDistributed('SCSS-')->clear();
  242. $appDirectory = $this->appData->getDirectoryListing();
  243. foreach ($appDirectory as $folder) {
  244. foreach ($folder->getDirectoryListing() as $file) {
  245. $file->delete();
  246. }
  247. }
  248. }
  249. /**
  250. * @return string SCSS code for variables from OC_Defaults
  251. */
  252. private function getInjectedVariables(): string {
  253. if ($this->injectedVariables !== null) {
  254. return $this->injectedVariables;
  255. }
  256. $variables = '';
  257. foreach ($this->defaults->getScssVariables() as $key => $value) {
  258. $variables .= '$' . $key . ': ' . $value . ';';
  259. }
  260. // check for valid variables / otherwise fall back to defaults
  261. try {
  262. $scss = new Compiler();
  263. $scss->compile($variables);
  264. $this->injectedVariables = $variables;
  265. } catch (ParserException $e) {
  266. $this->logger->error($e, ['app' => 'core']);
  267. }
  268. return $variables;
  269. }
  270. /**
  271. * Add the correct uri prefix to make uri valid again
  272. * @param string $css
  273. * @param string $webDir
  274. * @return string
  275. */
  276. private function rebaseUrls(string $css, string $webDir): string {
  277. $re = '/url\([\'"]([^\/][\.\w?=\/-]*)[\'"]\)/x';
  278. $subst = 'url(\''.$webDir.'/$1\')';
  279. return preg_replace($re, $subst, $css);
  280. }
  281. /**
  282. * Return the cached css file uri
  283. * @param string $appName the app name
  284. * @param string $fileName
  285. * @return string
  286. */
  287. public function getCachedSCSS(string $appName, string $fileName): string {
  288. $tmpfileLoc = explode('/', $fileName);
  289. $fileName = array_pop($tmpfileLoc);
  290. $fileName = $this->prependBaseurlPrefix(str_replace('.scss', '.css', $fileName));
  291. return substr($this->urlGenerator->linkToRoute('core.Css.getCss', ['fileName' => $fileName, 'appName' => $appName]), strlen(\OC::$WEBROOT) + 1);
  292. }
  293. /**
  294. * Prepend hashed base url to the css file
  295. * @param string$cssFile
  296. * @return string
  297. */
  298. private function prependBaseurlPrefix(string $cssFile): string {
  299. $frontendController = ($this->config->getSystemValue('htaccess.IgnoreFrontController', false) === true || getenv('front_controller_active') === 'true');
  300. return substr(md5($this->urlGenerator->getBaseUrl() . $frontendController), 0, 8) . '-' . $cssFile;
  301. }
  302. /**
  303. * Get WebDir root
  304. * @param string $path the css file path
  305. * @param string $appName the app name
  306. * @param string $serverRoot the server root path
  307. * @param string $webRoot the nextcloud installation root path
  308. * @return string the webDir
  309. */
  310. private function getWebDir(string $path, string $appName, string $serverRoot, string $webRoot): string {
  311. // Detect if path is within server root AND if path is within an app path
  312. if ( strpos($path, $serverRoot) === false && $appWebPath = \OC_App::getAppWebPath($appName)) {
  313. // Get the file path within the app directory
  314. $appDirectoryPath = explode($appName, $path)[1];
  315. // Remove the webroot
  316. return str_replace($webRoot, '', $appWebPath.$appDirectoryPath);
  317. }
  318. return $webRoot.substr($path, strlen($serverRoot));
  319. }
  320. }