diff --git a/lib/csslib.php b/lib/csslib.php index a7c5e40e123..bec0ed84ac8 100644 --- a/lib/csslib.php +++ b/lib/csslib.php @@ -329,6 +329,26 @@ function css_send_cached_css_content($csscontent, $etag) { die; } +/** + * Sends CSS directly and disables all caching. + * The Content-Length of the body is also included, but the script is not ended. + * + * @param string $css The CSS content to send + * @param int $expiry The anticipated expiry of the file + */ +function css_send_temporary_css($css) { + header('Cache-Control: no-cache, no-store, must-revalidate'); + header('Pragma: no-cache'); + header('Expires: 0'); + header('Content-Disposition: inline; filename="styles_debug.php"'); + header('Last-Modified: '. gmdate('D, d M Y H:i:s', time()) .' GMT'); + header('Accept-Ranges: none'); + header('Content-Type: text/css; charset=utf-8'); + header('Content-Length: ' . strlen($css)); + + echo $css; +} + /** * Sends CSS directly without caching it. * @@ -382,4 +402,4 @@ function css_send_unmodified($lastmodified, $etag) { function css_send_css_not_found() { header('HTTP/1.0 404 not found'); die('CSS was not found, sorry.'); -} \ No newline at end of file +} diff --git a/lib/outputlib.php b/lib/outputlib.php index d91e27e44dc..ca3b355a0b8 100644 --- a/lib/outputlib.php +++ b/lib/outputlib.php @@ -363,6 +363,12 @@ class theme_config { */ public $editor_sheets = array(); + /** + * @var bool Whether a fallback version of the stylesheet will be used + * whilst the final version is generated. + */ + public $usefallback = false; + /** * @var array The names of all the javascript files this theme that you would * like included from head, in order. Give the names of the files without .js. @@ -724,7 +730,7 @@ class theme_config { } $configurable = array( - 'parents', 'sheets', 'parents_exclude_sheets', 'plugins_exclude_sheets', + 'parents', 'sheets', 'parents_exclude_sheets', 'plugins_exclude_sheets', 'usefallback', 'javascripts', 'javascripts_footer', 'parents_exclude_javascripts', 'layouts', 'enable_dock', 'enablecourseajax', 'requiredblocks', 'rendererfactory', 'csspostprocess', 'editor_sheets', 'rarrow', 'larrow', 'uarrow', 'darrow', @@ -1102,6 +1108,19 @@ class theme_config { return $cache->set($key, $csscontent); } + /** + * Return whether the post processed CSS content has been cached. + * + * @return bool Whether the post-processed CSS is available in the cache. + */ + public function has_css_cached_content() { + + $key = $this->get_css_cache_key(); + $cache = cache::make('core', 'postprocessedcss'); + + return $cache->has($key); + } + /** * Return cached post processed CSS content. * @@ -2218,6 +2237,15 @@ class theme_config { $this->rtlmode = $inrtl; } + /** + * Whether the theme is being served in RTL mode. + * + * @return bool True when in RTL mode. + */ + public function get_rtl_mode() { + return $this->rtlmode; + } + /** * Checks if file with any image extension exists. * diff --git a/theme/boost/config.php b/theme/boost/config.php index 57e81c4f4b8..a628ff8dfc7 100644 --- a/theme/boost/config.php +++ b/theme/boost/config.php @@ -29,6 +29,7 @@ require_once(__DIR__ . '/lib.php'); $THEME->name = 'boost'; $THEME->sheets = []; $THEME->editor_sheets = []; +$THEME->usefallback = true; $THEME->scss = function($theme) { return theme_boost_get_main_scss_content($theme); }; diff --git a/theme/more/config.php b/theme/more/config.php index 82c90735724..09991ee3e61 100644 --- a/theme/more/config.php +++ b/theme/more/config.php @@ -28,6 +28,7 @@ $THEME->parents = array('clean', 'bootstrapbase'); $THEME->doctype = 'html5'; $THEME->sheets = array('custom'); $THEME->lessfile = 'moodle'; +$THEME->usefallback = true; $THEME->parents_exclude_sheets = array('bootstrapbase' => array('moodle'), 'clean' => array('custom')); $THEME->lessvariablescallback = 'theme_more_less_variables'; $THEME->extralesscallback = 'theme_more_extra_less'; diff --git a/theme/styles.php b/theme/styles.php index 36daa032acc..4977095decd 100644 --- a/theme/styles.php +++ b/theme/styles.php @@ -77,6 +77,7 @@ if (is_null($themesubrev)) { $themesubrev = min_clean_param($themesubrev, 'INT'); } +// Check that type fits into the expected values. if ($type === 'editor') { // The editor CSS is never chunked. $chunk = null; @@ -96,28 +97,17 @@ if (file_exists("$CFG->dirroot/theme/$themename/config.php")) { } $candidatedir = "$CFG->localcachedir/theme/$rev/$themename/css"; -$etag = "$rev/$themename/$type/$themesubrev"; -$candidatename = ($themesubrev > 0) ? "{$type}_{$themesubrev}" : $type; -if (!$usesvg) { - // Add to the sheet name, one day we'll be able to just drop this. - $candidatedir .= '/nosvg'; - $etag .= '/nosvg'; -} +$candidatesheet = "{$candidatedir}/" . theme_styles_get_filename($type, $themesubrev, $usesvg); +$chunkedcandidatesheet = "{$candidatedir}/" . theme_styles_get_filename($type, $themesubrev, $usesvg, $chunk); +$etag = theme_styles_get_etag($themename, $rev, $type, $themesubrev, $usesvg, $chunk); -if ($chunk !== null) { - $etag .= '/chunk'.$chunk; - $candidatename .= '.'.$chunk; -} -$candidatesheet = "$candidatedir/$candidatename.css"; -$etag = sha1($etag); - -if (file_exists($candidatesheet)) { +if (file_exists($chunkedcandidatesheet)) { if (!empty($_SERVER['HTTP_IF_NONE_MATCH']) || !empty($_SERVER['HTTP_IF_MODIFIED_SINCE'])) { // We do not actually need to verify the etag value because our files // never change in cache because we increment the rev counter. - css_send_unmodified(filemtime($candidatesheet), $etag); + css_send_unmodified(filemtime($chunkedcandidatesheet), $etag); } - css_send_cached_css($candidatesheet, $etag); + css_send_cached_css($chunkedcandidatesheet, $etag); } // Ok, now we need to start normal moodle script, we need to load all libs and $DB. @@ -139,94 +129,208 @@ $cache = true; // If the client is requesting a revision that doesn't match both // the global theme revision and the theme specific revision then // tell the browser not to cache this style sheet because it's -// likely being regnerated. +// likely being regenerated. if ($themerev <= 0 or $themerev != $rev or $themesubrev != $currentthemesubrev) { $rev = $themerev; $cache = false; $candidatedir = "$CFG->localcachedir/theme/$rev/$themename/css"; - $etag = "$rev/$themename/$type/$themesubrev"; - $candidatename = ($themesubrev > 0) ? "{$type}_{$themesubrev}" : $type; - if (!$usesvg) { - // Add to the sheet name, one day we'll be able to just drop this. - $candidatedir .= '/nosvg'; - $etag .= '/nosvg'; - } - - if ($chunk !== null) { - $etag .= '/chunk'.$chunk; - $candidatename .= '.'.$chunk; - } - $candidatesheet = "$candidatedir/$candidatename.css"; - $etag = sha1($etag); + $candidatesheet = "{$candidatedir}/" . theme_styles_get_filename($type, $themesubrev, $usesvg); + $chunkedcandidatesheet = "{$candidatedir}/" . theme_styles_get_filename($type, $themesubrev, $usesvg, $chunk); + $etag = theme_styles_get_etag($themename, $rev, $type, $themesubrev, $usesvg, $chunk); } make_localcache_directory('theme', false); if ($type === 'editor') { $csscontent = $theme->get_css_content_editor(); - css_store_css($theme, "$candidatedir/editor.css", $csscontent, false); + css_store_css($theme, $candidatesheet, $csscontent, false); -} else { - // Fetch a lock whilst the CSS is fetched as this can be slow and CPU intensive. - // Each client should wait for one to finish the compilation before starting the compiler. - $lockfactory = \core\lock\lock_config::get_lock_factory('core_theme_get_css_content'); - $lock = $lockfactory->get_lock($themename, rand(90, 120)); - - if (file_exists($candidatesheet)) { - // The file was built while we waited for the lock, we release the lock and serve the file. - if ($lock) { - $lock->release(); - } - - if ($cache) { - css_send_cached_css($candidatesheet, $etag); - } else { - css_send_uncached_css(file_get_contents($candidatesheet)); - } + if ($cache) { + css_send_cached_css($candidatesheet, $etag); + } else { + css_send_uncached_css(file_get_contents($candidatesheet)); } - // The lock is still held, and the sheet still does not exist. - // Compile the CSS content. +} + +if (($fallbacksheet = theme_styles_fallback_content($theme)) && !$theme->has_css_cached_content()) { + // The theme is not yet available and a fallback is available. + // Return the fallback immediately, specifying the Content-Length, then generate in the background. + $css = file_get_contents($fallbacksheet); + css_send_temporary_css($css); + + // The fallback content has now been sent. + // There will be an attempt to generate the content, but it should not be served. + // The Content-Length above means that the client will disregard it anyway. + $sendaftergeneration = false; + + // There may be another client currently holding a lock and generating the stylesheet. + // Use a very low lock timeout as the connection will be ended immediately afterwards. + $locktimeout = 1; +} else { + // There is no fallback content to be issued here, therefore the generated content must be output. + $sendaftergeneration = true; + + // Use a realistic lock timeout as the intention is to avoid lock contention. + $locktimeout = rand(90, 120); +} + +// Attempt to fetch the lock. +$lockfactory = \core\lock\lock_config::get_lock_factory('core_theme_get_css_content'); +$lock = $lockfactory->get_lock($themename, $locktimeout); + +if ($sendaftergeneration || $lock) { + // Either the lock was successful, or the lock was unsuccessful but the content *must* be sent. + if (!file_exists($chunkedcandidatesheet)) { + // The content does not exist locally. + // Generate and save it. + $candidatesheet = theme_styles_generate_and_store($theme, $rev, $themesubrev, $candidatedir); + } + + if ($lock) { + $lock->release(); + } + + if ($sendaftergeneration) { + if (!$cache) { + // Do not pollute browser caches if invalid revision requested, + // let's ignore legacy IE breakage here too. + css_send_uncached_css(file_get_contents($candidatesheet)); + + } else if ($chunk !== null and file_exists($chunkedcandidatesheet)) { + // Greetings stupid legacy IEs! + css_send_cached_css($chunkedcandidatesheet, $etag); + + } else { + // Real browsers - this is the expected result! + css_send_cached_css($candidatesheet, $etag); + } + } +} + +/** + * Generate the theme CSS and store it. + * + * @param theme_config $theme The theme to be generated + * @param int $rev The theme revision + * @param int $themesubrev The theme sub-revision + * @param string $candidatedir The directory that it should be stored in + * @return string The path that the primary (non-chunked) CSS was written to + */ +function theme_styles_generate_and_store($theme, $rev, $themesubrev, $candidatedir) { + global $CFG; + + // Generate the content first. if (!$csscontent = $theme->get_css_cached_content()) { $csscontent = $theme->get_css_content(); $theme->set_css_content_cache($csscontent); } + if ($theme->get_rtl_mode()) { + $type = "all-rtl"; + } else { + $type = "all"; + } + + // Determine the candidatesheet path. + // Note: Do not pass any value for chunking as this is calcualted during css storage. + $candidatesheet = "{$candidatedir}/" . theme_styles_get_filename($type, $themesubrev, $theme->use_svg_icons()); + + // Determine the chunking URL. + // Note, this will be removed when support for IE9 is removed. $relroot = preg_replace('|^http.?://[^/]+|', '', $CFG->wwwroot); - if (!empty($slashargument)) { - if ($usesvg) { - $chunkurl = "{$relroot}/theme/styles.php/{$themename}/{$rev}/$type"; + if (!empty(min_get_slash_argument())) { + if ($theme->use_svg_icons()) { + $chunkurl = "{$relroot}/theme/styles.php/{$theme->name}/{$rev}/$type"; } else { - $chunkurl = "{$relroot}/theme/styles.php/_s/{$themename}/{$rev}/$type"; + $chunkurl = "{$relroot}/theme/styles.php/_s/{$theme->name}/{$rev}/$type"; } } else { - if ($usesvg) { - $chunkurl = "{$relroot}/theme/styles.php?theme={$themename}&rev={$rev}&type=$type"; + if ($theme->use_svg_icons()) { + $chunkurl = "{$relroot}/theme/styles.php?theme={$theme->name}&rev={$rev}&type=$type"; } else { - $chunkurl = "{$relroot}/theme/styles.php?theme={$themename}&rev={$rev}&type=$type&svg=0"; + $chunkurl = "{$relroot}/theme/styles.php?theme={$theme->name}&rev={$rev}&type=$type&svg=0"; } } - css_store_css($theme, "$candidatedir/$candidatename.css", $csscontent, true, $chunkurl); + // Store the CSS. + css_store_css($theme, $candidatesheet, $csscontent, true, $chunkurl); - if ($lock) { - // Now that the CSS has been generated and/or stored, release the lock. - // This will allow waiting clients to use the newly generated and stored CSS. - $lock->release(); + // Store the fallback CSS in the temp directory. + // This file is used as a fallback when waiting for a theme to compile and is not versioned in any way. + $fallbacksheet = make_temp_directory("theme/{$theme->name}") + . "/" + . theme_styles_get_filename($type, 0, $theme->use_svg_icons()); + css_store_css($theme, $fallbacksheet, $csscontent, true, $chunkurl); + + return $candidatesheet; +} + +/** + * Fetch the preferred fallback content location if available. + * + * @param theme_config $theme The theme to be generated + * @return string The path to the fallback sheet on disk + */ +function theme_styles_fallback_content($theme) { + global $CFG; + + if (!$theme->usefallback) { + // This theme does not support fallbacks. + return false; } + + $type = $theme->get_rtl_mode() ? 'all-rtl' : 'all'; + $filename = theme_styles_get_filename($type); + + $fallbacksheet = "{$CFG->tempdir}/theme/{$theme->name}/{$filename}"; + if (file_exists($fallbacksheet)) { + return $fallbacksheet; + } + + return false; } -if (!$cache) { - // Do not pollute browser caches if invalid revision requested, - // let's ignore legacy IE breakage here too. - css_send_uncached_css($csscontent); +/** + * Get the filename for the specified configuration. + * + * @param string $type The requested sheet type + * @param int $themesubrev The theme sub-revision + * @param bool $usesvg Whether SVGs are allowed + * @param int $chunk The chunk number if specified + * @return string The filename for this sheet + */ +function theme_styles_get_filename($type, $themesubrev = 0, $usesvg = true, $chunk = null) { + $filename = $type; + $filename .= ($themesubrev > 0) ? "_{$themesubrev}" : ''; + $filename .= $usesvg ? '' : '-nosvg'; + $filename .= $chunk ? ".{$chunk}" : ''; -} else if ($chunk !== null and file_exists($candidatesheet)) { - // Greetings stupid legacy IEs! - css_send_cached_css($candidatesheet, $etag); - -} else { - // Real browsers - this is the expected result! - css_send_cached_css_content($csscontent, $etag); + return "{$filename}.css"; +} + +/** + * Determine the correct etag for the specified configuration. + * + * @param string $themename The name of the theme + * @param int $rev The revision number + * @param string $type The requested sheet type + * @param int $themesubrev The theme sub-revision + * @param bool $usesvg Whether SVGs are allowed + * @param int $chunk The chunk number if specified + * @return string The etag to use for this request + */ +function theme_styles_get_etag($themename, $rev, $type, $themesubrev, $usesvg, $chunk) { + $etag = [$rev, $themename, $type, $themesubrev]; + + if (!$usesvg) { + $etag[] = 'nosvg'; + } + + if ($chunk) { + $etag[] = "chunk{$chunk}"; + } + + return sha1(implode('/', $etag)); }