* @author Trevor Parscal
*/
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+
/**
* Dynamic JavaScript and CSS resource loading system.
*
* Most of the documentation is on the MediaWiki documentation wiki starting at:
* https://www.mediawiki.org/wiki/ResourceLoader
*/
-class ResourceLoader {
+class ResourceLoader implements LoggerAwareInterface {
/** @var int */
protected static $filterCacheVersion = 7;
*/
protected $blobStore;
+ /**
+ * @var LoggerInterface
+ */
+ private $logger;
+
/**
* Load information stored in the database about modules.
*
*
* @param string $filter Name of filter to run
* @param string $data Text to filter, such as JavaScript or CSS text
- * @param string $cacheReport Whether to include the cache key report
+ * @param array $options For back-compat, can also be the boolean value for "cacheReport". Keys:
+ * - (bool) cache: Whether to allow caching this data. Default: true.
+ * - (bool) cacheReport: Whether to include the "cache key" report comment. Default: true.
* @return string Filtered data, or a comment containing an error message
*/
- public function filter( $filter, $data, $cacheReport = true ) {
+ public function filter( $filter, $data, $options = array() ) {
+ // Back-compat
+ if ( is_bool( $options ) ) {
+ $options = array( 'cacheReport' => $options );
+ }
+ // Defaults
+ $options += array( 'cache' => true, 'cacheReport' => true );
- // For empty/whitespace-only data or for unknown filters, don't perform
- // any caching or processing
- if ( trim( $data ) === '' || !in_array( $filter, array( 'minify-js', 'minify-css' ) ) ) {
+ // Don't filter empty content
+ if ( trim( $data ) === '' ) {
return $data;
}
- // Try for cache hit
- // Use CACHE_ANYTHING since filtering is very slow compared to DB queries
- $key = wfMemcKey( 'resourceloader', 'filter', $filter, self::$filterCacheVersion, md5( $data ) );
- $cache = wfGetCache( CACHE_ANYTHING );
- $cacheEntry = $cache->get( $key );
- if ( is_string( $cacheEntry ) ) {
- wfIncrStats( "rl-$filter-cache-hits" );
- return $cacheEntry;
+ if ( !in_array( $filter, array( 'minify-js', 'minify-css' ) ) ) {
+ $this->logger->warning( 'Invalid filter {filter}', array(
+ 'filter' => $filter
+ ) );
+ return $data;
}
- $result = '';
- // Run the filter - we've already verified one of these will work
- try {
- wfIncrStats( "rl-$filter-cache-misses" );
- switch ( $filter ) {
- case 'minify-js':
- $result = JavaScriptMinifier::minify( $data,
- $this->config->get( 'ResourceLoaderMinifierStatementsOnOwnLine' ),
- $this->config->get( 'ResourceLoaderMinifierMaxLineLength' )
- );
- if ( $cacheReport ) {
- $result .= "\n/* cache key: $key */";
- }
- break;
- case 'minify-css':
- $result = CSSMin::minify( $data );
- if ( $cacheReport ) {
- $result .= "\n/* cache key: $key */";
- }
- break;
+ if ( !$options['cache'] ) {
+ $result = $this->applyFilter( $filter, $data );
+ } else {
+ $key = wfMemcKey( 'resourceloader', 'filter', $filter, self::$filterCacheVersion, md5( $data ) );
+ $cache = wfGetCache( wfIsHHVM() ? CACHE_ACCEL : CACHE_ANYTHING );
+ $cacheEntry = $cache->get( $key );
+ if ( is_string( $cacheEntry ) ) {
+ wfIncrStats( "resourceloader_cache.$filter.hit" );
+ return $cacheEntry;
+ }
+ $result = '';
+ try {
+ $result = $this->applyFilter( $filter, $data );
+ if ( $options['cacheReport'] ) {
+ $result .= "\n/* cache key: $key */";
+ }
+ $cache->set( $key, $result );
+ } catch ( Exception $e ) {
+ MWExceptionHandler::logException( $e );
+ $this->logger->warning( 'Minification failed: {exception}', array(
+ 'exception' => $e
+ ) );
+ $this->errors[] = self::formatExceptionNoComment( $e );
}
-
- // Save filtered text to Memcached
- $cache->set( $key, $result );
- } catch ( Exception $e ) {
- MWExceptionHandler::logException( $e );
- wfDebugLog( 'resourceloader', __METHOD__ . ": minification failed: $e" );
- $this->errors[] = self::formatExceptionNoComment( $e );
}
return $result;
}
+ private function applyFilter( $filter, $data ) {
+ $stats = RequestContext::getMain()->getStats();
+ $statStart = microtime( true );
+
+ switch ( $filter ) {
+ case 'minify-js':
+ $data = JavaScriptMinifier::minify( $data,
+ $this->config->get( 'ResourceLoaderMinifierStatementsOnOwnLine' ),
+ $this->config->get( 'ResourceLoaderMinifierMaxLineLength' )
+ );
+ break;
+ case 'minify-css':
+ $data = CSSMin::minify( $data );
+ break;
+ }
+
+ $stats->timing( "resourceloader_cache.$filter.miss", microtime( true ) - $statStart );
+ return $data;
+ }
+
/* Methods */
/**
* Register core modules and runs registration hooks.
* @param Config|null $config
*/
- public function __construct( Config $config = null ) {
+ public function __construct( Config $config = null, LoggerInterface $logger = null ) {
global $IP;
- if ( $config === null ) {
- wfDebug( __METHOD__ . ' was called without providing a Config instance' );
- $config = ConfigFactory::getDefaultInstance()->makeConfig( 'main' );
+ if ( !$logger ) {
+ $logger = new NullLogger();
}
+ $this->setLogger( $logger );
+ if ( !$config ) {
+ $this->logger->debug( __METHOD__ . ' was called without providing a Config instance' );
+ $config = ConfigFactory::getDefaultInstance()->makeConfig( 'main' );
+ }
$this->config = $config;
// Add 'local' source first
// Register core modules
$this->register( include "$IP/resources/Resources.php" );
+ $this->register( include "$IP/resources/ResourcesOOUI.php" );
// Register extension modules
Hooks::run( 'ResourceLoaderRegisterModules', array( &$this ) );
$this->register( $config->get( 'ResourceModules' ) );
return $this->config;
}
+ public function setLogger( LoggerInterface $logger ) {
+ $this->logger = $logger;
+ }
+
+ /**
+ * @since 1.26
+ * @return MessageBlobStore
+ */
+ public function getMessageBlobStore() {
+ return $this->blobStore;
+ }
+
/**
- * @param MessageBlobStore $blobStore
* @since 1.25
+ * @param MessageBlobStore $blobStore
*/
public function setMessageBlobStore( MessageBlobStore $blobStore ) {
$this->blobStore = $blobStore;
return $this->sources[$source];
}
+ /**
+ * @since 1.26
+ * @param string $value
+ * @return string Hash
+ */
+ public static function makeHash( $value ) {
+ // Use base64 to output more entropy in a more compact string (default hex is only base16).
+ // The first 8 chars of a base64 encoded digest represent the same binary as
+ // the first 12 chars of a hex encoded digest.
+ return substr( base64_encode( sha1( $value, true ) ), 0, 8 );
+ }
+
+ /**
+ * Helper method to get and combine versions of multiple modules.
+ *
+ * @since 1.26
+ * @param ResourceLoaderContext $context
+ * @param array $modules List of ResourceLoaderModule objects
+ * @return string Hash
+ */
+ public function getCombinedVersion( ResourceLoaderContext $context, Array $modules ) {
+ if ( !$modules ) {
+ return '';
+ }
+ // Support: PHP 5.3 ("$this" for anonymous functions was added in PHP 5.4.0)
+ // http://php.net/functions.anonymous
+ $rl = $this;
+ $hashes = array_map( function ( $module ) use ( $rl, $context ) {
+ return $rl->getModule( $module )->getVersionHash( $context );
+ }, $modules );
+ return self::makeHash( implode( $hashes ) );
+ }
+
/**
* Output a response to a load request, including the content-type header.
*
* @param ResourceLoaderContext $context Context in which a response should be formed
*/
public function respond( ResourceLoaderContext $context ) {
- // Use file cache if enabled and available...
- if ( $this->config->get( 'UseFileCache' ) ) {
- $fileCache = ResourceFileCache::newFromContext( $context );
- if ( $this->tryRespondFromFileCache( $fileCache, $context ) ) {
- return; // output handled
- }
- }
-
// Buffer output to catch warnings. Normally we'd use ob_clean() on the
// top-level output buffer to clear warnings, but that breaks when ob_gzhandler
// is used: ob_clean() will clear the GZIP header in that case and it won't come
// Do not allow private modules to be loaded from the web.
// This is a security issue, see bug 34907.
if ( $module->getGroup() === 'private' ) {
- wfDebugLog( 'resourceloader', __METHOD__ . ": request for private module '$name' denied" );
+ $this->logger->debug( "Request for private module '$name' denied" );
$this->errors[] = "Cannot show private module \"$name\"";
continue;
}
}
}
- // Preload information needed to the mtime calculation below
try {
+ // Preload for getCombinedVersion()
$this->preloadModuleInfo( array_keys( $modules ), $context );
} catch ( Exception $e ) {
MWExceptionHandler::logException( $e );
- wfDebugLog( 'resourceloader', __METHOD__ . ": preloading module info failed: $e" );
+ $this->logger->warning( 'Preloading module info failed: {exception}', array(
+ 'exception' => $e
+ ) );
$this->errors[] = self::formatExceptionNoComment( $e );
}
- // To send Last-Modified and support If-Modified-Since, we need to detect
- // the last modified time
- $mtime = wfTimestamp( TS_UNIX, $this->config->get( 'CacheEpoch' ) );
- foreach ( $modules as $module ) {
- /**
- * @var $module ResourceLoaderModule
- */
- try {
- // Calculate maximum modified time
- $mtime = max( $mtime, $module->getModifiedTime( $context ) );
- } catch ( Exception $e ) {
- MWExceptionHandler::logException( $e );
- wfDebugLog( 'resourceloader', __METHOD__ . ": calculating maximum modified time failed: $e" );
- $this->errors[] = self::formatExceptionNoComment( $e );
- }
+ // Combine versions to propagate cache invalidation
+ $versionHash = '';
+ try {
+ $versionHash = $this->getCombinedVersion( $context, array_keys( $modules ) );
+ } catch ( Exception $e ) {
+ MWExceptionHandler::logException( $e );
+ $this->logger->warning( 'Calculating version hash failed: {exception}', array(
+ 'exception' => $e
+ ) );
+ $this->errors[] = self::formatExceptionNoComment( $e );
}
- // If there's an If-Modified-Since header, respond with a 304 appropriately
- if ( $this->tryRespondLastModified( $context, $mtime ) ) {
+ // See RFC 2616 ยง 3.11 Entity Tags
+ // http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.11
+ $etag = 'W/"' . $versionHash . '"';
+
+ // Try the client-side cache first
+ if ( $this->tryRespondNotModified( $context, $etag ) ) {
return; // output handled (buffers cleared)
}
+ // Use file cache if enabled and available...
+ if ( $this->config->get( 'UseFileCache' ) ) {
+ $fileCache = ResourceFileCache::newFromContext( $context );
+ if ( $this->tryRespondFromFileCache( $fileCache, $context, $etag ) ) {
+ return; // output handled
+ }
+ }
+
// Generate a response
$response = $this->makeModuleResponse( $context, $modules, $missing );
}
}
- // Send content type and cache related headers
- $this->sendResponseHeaders( $context, $mtime, (bool)$this->errors );
+ $this->sendResponseHeaders( $context, $etag, (bool)$this->errors );
// Remove the output buffer and output the response
ob_end_clean();
}
/**
- * Send content type and last modified headers to the client.
+ * Send main response headers to the client.
+ *
+ * Deals with Content-Type, CORS (for stylesheets), and caching.
+ *
* @param ResourceLoaderContext $context
- * @param string $mtime TS_MW timestamp to use for last-modified
+ * @param string $etag ETag header value
* @param bool $errors Whether there are errors in the response
* @return void
*/
- protected function sendResponseHeaders( ResourceLoaderContext $context, $mtime, $errors ) {
+ protected function sendResponseHeaders( ResourceLoaderContext $context, $etag, $errors ) {
$rlMaxage = $this->config->get( 'ResourceLoaderMaxage' );
// If a version wasn't specified we need a shorter expiry time for updates
// to propagate to clients quickly
} else {
header( 'Content-Type: text/javascript; charset=utf-8' );
}
- header( 'Last-Modified: ' . wfTimestamp( TS_RFC2822, $mtime ) );
+ // See RFC 2616 ยง 14.19 ETag
+ // http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.19
+ header( 'ETag: ' . $etag );
if ( $context->getDebug() ) {
// Do not cache debug responses
header( 'Cache-Control: private, no-cache, must-revalidate' );
}
/**
- * Respond with 304 Last Modified if appropiate.
+ * Respond with HTTP 304 Not Modified if appropiate.
*
- * If there's an If-Modified-Since header, respond with a 304 appropriately
+ * If there's an If-None-Match header, respond with a 304 appropriately
* and clear out the output buffer. If the client cache is too old then do nothing.
*
* @param ResourceLoaderContext $context
- * @param string $mtime The TS_MW timestamp to check the header against
- * @return bool True if 304 header sent and output handled
+ * @param string $etag ETag header value
+ * @return bool True if HTTP 304 was sent and output handled
*/
- protected function tryRespondLastModified( ResourceLoaderContext $context, $mtime ) {
- // If there's an If-Modified-Since header, respond with a 304 appropriately
- // Some clients send "timestamp;length=123". Strip the part after the first ';'
- // so we get a valid timestamp.
- $ims = $context->getRequest()->getHeader( 'If-Modified-Since' );
+ protected function tryRespondNotModified( ResourceLoaderContext $context, $etag ) {
+ // See RFC 2616 ยง 14.26 If-None-Match
+ // http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.26
+ $clientKeys = $context->getRequest()->getHeader( 'If-None-Match', WebRequest::GETHEADER_LIST );
// Never send 304s in debug mode
- if ( $ims !== false && !$context->getDebug() ) {
- $imsTS = strtok( $ims, ';' );
- if ( $mtime <= wfTimestamp( TS_UNIX, $imsTS ) ) {
- // There's another bug in ob_gzhandler (see also the comment at
- // the top of this function) that causes it to gzip even empty
- // responses, meaning it's impossible to produce a truly empty
- // response (because the gzip header is always there). This is
- // a problem because 304 responses have to be completely empty
- // per the HTTP spec, and Firefox behaves buggily when they're not.
- // See also http://bugs.php.net/bug.php?id=51579
- // To work around this, we tear down all output buffering before
- // sending the 304.
- wfResetOutputBuffers( /* $resetGzipEncoding = */ true );
-
- header( 'HTTP/1.0 304 Not Modified' );
- header( 'Status: 304 Not Modified' );
-
- // Send content type and cache headers
- $this->sendResponseHeaders( $context, $mtime, false );
- return true;
- }
+ if ( $clientKeys !== false && !$context->getDebug() && in_array( $etag, $clientKeys ) ) {
+ // There's another bug in ob_gzhandler (see also the comment at
+ // the top of this function) that causes it to gzip even empty
+ // responses, meaning it's impossible to produce a truly empty
+ // response (because the gzip header is always there). This is
+ // a problem because 304 responses have to be completely empty
+ // per the HTTP spec, and Firefox behaves buggily when they're not.
+ // See also http://bugs.php.net/bug.php?id=51579
+ // To work around this, we tear down all output buffering before
+ // sending the 304.
+ wfResetOutputBuffers( /* $resetGzipEncoding = */ true );
+
+ HttpStatus::header( 304 );
+
+ $this->sendResponseHeaders( $context, $etag, false );
+ return true;
}
return false;
}
*
* @param ResourceFileCache $fileCache Cache object for this request URL
* @param ResourceLoaderContext $context Context in which to generate a response
+ * @param string $etag ETag header value
* @return bool If this found a cache file and handled the response
*/
protected function tryRespondFromFileCache(
- ResourceFileCache $fileCache, ResourceLoaderContext $context
+ ResourceFileCache $fileCache,
+ ResourceLoaderContext $context,
+ $etag
) {
$rlMaxage = $this->config->get( 'ResourceLoaderMaxage' );
// Buffer output to catch warnings.
}
if ( $good ) {
$ts = $fileCache->cacheTimestamp();
- // If there's an If-Modified-Since header, respond with a 304 appropriately
- if ( $this->tryRespondLastModified( $context, $ts ) ) {
- return false; // output handled (buffers cleared)
- }
// Send content type and cache headers
- $this->sendResponseHeaders( $context, $ts, false );
+ $this->sendResponseHeaders( $context, $etag, false );
$response = $fileCache->fetchText();
// Capture any PHP warnings from the output buffer and append them to the
// response in a comment if we're in debug mode.
// Pre-fetch blobs
if ( $context->shouldIncludeMessages() ) {
try {
- $blobs = $this->blobStore->get( $this, $modules, $context->getLanguage() );
+ $this->blobStore->get( $this, $modules, $context->getLanguage() );
} catch ( Exception $e ) {
MWExceptionHandler::logException( $e );
- wfDebugLog(
- 'resourceloader',
- __METHOD__ . ": pre-fetching blobs from MessageBlobStore failed: $e"
- );
+ $this->logger->warning( 'Prefetching MessageBlobStore failed: {exception}', array(
+ 'exception' => $e
+ ) );
$this->errors[] = self::formatExceptionNoComment( $e );
}
- } else {
- $blobs = array();
}
foreach ( $missing as $name ) {
// Generate output
$isRaw = false;
foreach ( $modules as $name => $module ) {
- /**
- * @var $module ResourceLoaderModule
- */
-
try {
- $scripts = '';
- if ( $context->shouldIncludeScripts() ) {
- // If we are in debug mode, we'll want to return an array of URLs if possible
- // However, we can't do this if the module doesn't support it
- // We also can't do this if there is an only= parameter, because we have to give
- // the module a way to return a load.php URL without causing an infinite loop
- if ( $context->getDebug() && !$context->getOnly() && $module->supportsURLLoading() ) {
- $scripts = $module->getScriptURLsForDebug( $context );
- } else {
- $scripts = $module->getScript( $context );
- // rtrim() because there are usually a few line breaks
- // after the last ';'. A new line at EOF, a new line
- // added by ResourceLoaderFileModule::readScriptFiles, etc.
- if ( is_string( $scripts )
- && strlen( $scripts )
- && substr( rtrim( $scripts ), -1 ) !== ';'
- ) {
- // Append semicolon to prevent weird bugs caused by files not
- // terminating their statements right (bug 27054)
- $scripts .= ";\n";
- }
- }
- }
- // Styles
- $styles = array();
- if ( $context->shouldIncludeStyles() ) {
- // Don't create empty stylesheets like array( '' => '' ) for modules
- // that don't *have* any stylesheets (bug 38024).
- $stylePairs = $module->getStyles( $context );
- if ( count( $stylePairs ) ) {
- // If we are in debug mode without &only= set, we'll want to return an array of URLs
- // See comment near shouldIncludeScripts() for more details
- if ( $context->getDebug() && !$context->getOnly() && $module->supportsURLLoading() ) {
- $styles = array(
- 'url' => $module->getStyleURLsForDebug( $context )
- );
- } else {
- // Minify CSS before embedding in mw.loader.implement call
- // (unless in debug mode)
- if ( !$context->getDebug() ) {
- foreach ( $stylePairs as $media => $style ) {
- // Can be either a string or an array of strings.
- if ( is_array( $style ) ) {
- $stylePairs[$media] = array();
- foreach ( $style as $cssText ) {
- if ( is_string( $cssText ) ) {
- $stylePairs[$media][] = $this->filter( 'minify-css', $cssText );
- }
- }
- } elseif ( is_string( $style ) ) {
- $stylePairs[$media] = $this->filter( 'minify-css', $style );
- }
- }
- }
- // Wrap styles into @media groups as needed and flatten into a numerical array
- $styles = array(
- 'css' => self::makeCombinedStyles( $stylePairs )
- );
- }
- }
- }
-
- // Messages
- $messagesBlob = isset( $blobs[$name] ) ? $blobs[$name] : '{}';
+ $content = $module->getModuleContent( $context );
// Append output
switch ( $context->getOnly() ) {
case 'scripts':
+ $scripts = $content['scripts'];
if ( is_string( $scripts ) ) {
// Load scripts raw...
$out .= $scripts;
}
break;
case 'styles':
+ $styles = $content['styles'];
// We no longer seperate into media, they are all combined now with
// custom media type groups into @media .. {} sections as part of the css string.
// Module returns either an empty array or a numerical array with css strings.
default:
$out .= self::makeLoaderImplementScript(
$name,
- $scripts,
- $styles,
- new XmlJsCode( $messagesBlob ),
- $module->getTemplates()
+ isset( $content['scripts'] ) ? $content['scripts'] : '',
+ isset( $content['styles'] ) ? $content['styles'] : array(),
+ isset( $content['messagesBlob'] ) ? new XmlJsCode( $content['messagesBlob'] ) : array(),
+ isset( $content['templates'] ) ? $content['templates'] : array()
);
break;
}
} catch ( Exception $e ) {
MWExceptionHandler::logException( $e );
- wfDebugLog( 'resourceloader', __METHOD__ . ": generating module package failed: $e" );
+ $this->logger->warning( 'Generating module package failed: {exception}', array(
+ 'exception' => $e
+ ) );
$this->errors[] = self::formatExceptionNoComment( $e );
// Respond to client with error-state instead of module implementation
}
}
+ $enableFilterCache = true;
+ if ( count( $modules ) === 1 && reset( $modules ) instanceof ResourceLoaderUserTokensModule ) {
+ // If we're building the embedded user.tokens, don't cache (T84960)
+ $enableFilterCache = false;
+ }
+
if ( !$context->getDebug() ) {
if ( $context->getOnly() === 'styles' ) {
$out = $this->filter( 'minify-css', $out );
} else {
- $out = $this->filter( 'minify-js', $out );
+ $out = $this->filter( 'minify-js', $out, array(
+ 'cache' => $enableFilterCache
+ ) );
}
}
$module = array(
$name,
$scripts,
- (object) $styles,
- (object) $messages,
- (object) $templates,
+ (object)$styles,
+ (object)$messages,
+ (object)$templates,
);
self::trimArray( $module );
* and $group as supplied.
*
* @param string $name Module name
- * @param int $version Module version number as a timestamp
+ * @param string $version Module version hash
* @param array $dependencies List of module names on which this module depends
* @param string $group Group which the module is in.
* @param string $source Source of the module, or 'local' if not foreign.
* Registers modules with the given names and parameters.
*
* @param string $name Module name
- * @param int $version Module version number as a timestamp
+ * @param string $version Module version hash
* @param array $dependencies List of module names on which this module depends
* @param string $group Group which the module is in
* @param string $source Source of the module, or 'local' if not foreign
/**
* Build a load.php URL
- * @deprecated since 1.24, use createLoaderURL instead
+ * @deprecated since 1.24 Use createLoaderURL() instead
* @param array $modules Array of module names (strings)
* @param string $lang Language code
* @param string $skin Skin name