MediaWiki  REL1_24
ResourceLoader.php
Go to the documentation of this file.
00001 <?php
00031 class ResourceLoader {
00033     protected static $filterCacheVersion = 7;
00034 
00036     protected static $debugMode = null;
00037 
00039     protected $modules = array();
00040 
00042     protected $moduleInfos = array();
00043 
00045     private $config;
00046 
00051     protected $testModuleNames = array();
00052 
00054     protected $sources = array();
00055 
00057     protected $hasErrors = false;
00058 
00073     public function preloadModuleInfo( array $modules, ResourceLoaderContext $context ) {
00074         if ( !count( $modules ) ) {
00075             // Or else Database*::select() will explode, plus it's cheaper!
00076             return;
00077         }
00078         $dbr = wfGetDB( DB_SLAVE );
00079         $skin = $context->getSkin();
00080         $lang = $context->getLanguage();
00081 
00082         // Get file dependency information
00083         $res = $dbr->select( 'module_deps', array( 'md_module', 'md_deps' ), array(
00084                 'md_module' => $modules,
00085                 'md_skin' => $skin
00086             ), __METHOD__
00087         );
00088 
00089         // Set modules' dependencies
00090         $modulesWithDeps = array();
00091         foreach ( $res as $row ) {
00092             $module = $this->getModule( $row->md_module );
00093             if ( $module ) {
00094                 $module->setFileDependencies( $skin, FormatJson::decode( $row->md_deps, true ) );
00095                 $modulesWithDeps[] = $row->md_module;
00096             }
00097         }
00098 
00099         // Register the absence of a dependency row too
00100         foreach ( array_diff( $modules, $modulesWithDeps ) as $name ) {
00101             $module = $this->getModule( $name );
00102             if ( $module ) {
00103                 $this->getModule( $name )->setFileDependencies( $skin, array() );
00104             }
00105         }
00106 
00107         // Get message blob mtimes. Only do this for modules with messages
00108         $modulesWithMessages = array();
00109         foreach ( $modules as $name ) {
00110             $module = $this->getModule( $name );
00111             if ( $module && count( $module->getMessages() ) ) {
00112                 $modulesWithMessages[] = $name;
00113             }
00114         }
00115         $modulesWithoutMessages = array_flip( $modules ); // Will be trimmed down by the loop below
00116         if ( count( $modulesWithMessages ) ) {
00117             $res = $dbr->select( 'msg_resource', array( 'mr_resource', 'mr_timestamp' ), array(
00118                     'mr_resource' => $modulesWithMessages,
00119                     'mr_lang' => $lang
00120                 ), __METHOD__
00121             );
00122             foreach ( $res as $row ) {
00123                 $module = $this->getModule( $row->mr_resource );
00124                 if ( $module ) {
00125                     $module->setMsgBlobMtime( $lang, wfTimestamp( TS_UNIX, $row->mr_timestamp ) );
00126                     unset( $modulesWithoutMessages[$row->mr_resource] );
00127                 }
00128             }
00129         }
00130         foreach ( array_keys( $modulesWithoutMessages ) as $name ) {
00131             $module = $this->getModule( $name );
00132             if ( $module ) {
00133                 $module->setMsgBlobMtime( $lang, 0 );
00134             }
00135         }
00136     }
00137 
00154     public function filter( $filter, $data, $cacheReport = true ) {
00155         wfProfileIn( __METHOD__ );
00156 
00157         // For empty/whitespace-only data or for unknown filters, don't perform
00158         // any caching or processing
00159         if ( trim( $data ) === '' || !in_array( $filter, array( 'minify-js', 'minify-css' ) ) ) {
00160             wfProfileOut( __METHOD__ );
00161             return $data;
00162         }
00163 
00164         // Try for cache hit
00165         // Use CACHE_ANYTHING since filtering is very slow compared to DB queries
00166         $key = wfMemcKey( 'resourceloader', 'filter', $filter, self::$filterCacheVersion, md5( $data ) );
00167         $cache = wfGetCache( CACHE_ANYTHING );
00168         $cacheEntry = $cache->get( $key );
00169         if ( is_string( $cacheEntry ) ) {
00170             wfIncrStats( "rl-$filter-cache-hits" );
00171             wfProfileOut( __METHOD__ );
00172             return $cacheEntry;
00173         }
00174 
00175         $result = '';
00176         // Run the filter - we've already verified one of these will work
00177         try {
00178             wfIncrStats( "rl-$filter-cache-misses" );
00179             switch ( $filter ) {
00180                 case 'minify-js':
00181                     $result = JavaScriptMinifier::minify( $data,
00182                         $this->config->get( 'ResourceLoaderMinifierStatementsOnOwnLine' ),
00183                         $this->config->get( 'ResourceLoaderMinifierMaxLineLength' )
00184                     );
00185                     if ( $cacheReport ) {
00186                         $result .= "\n/* cache key: $key */";
00187                     }
00188                     break;
00189                 case 'minify-css':
00190                     $result = CSSMin::minify( $data );
00191                     if ( $cacheReport ) {
00192                         $result .= "\n/* cache key: $key */";
00193                     }
00194                     break;
00195             }
00196 
00197             // Save filtered text to Memcached
00198             $cache->set( $key, $result );
00199         } catch ( Exception $e ) {
00200             MWExceptionHandler::logException( $e );
00201             wfDebugLog( 'resourceloader', __METHOD__ . ": minification failed: $e" );
00202             $this->hasErrors = true;
00203             // Return exception as a comment
00204             $result = self::formatException( $e );
00205         }
00206 
00207         wfProfileOut( __METHOD__ );
00208 
00209         return $result;
00210     }
00211 
00212     /* Methods */
00213 
00218     public function __construct( Config $config = null ) {
00219         global $IP;
00220 
00221         wfProfileIn( __METHOD__ );
00222 
00223         if ( $config === null ) {
00224             wfDebug( __METHOD__ . ' was called without providing a Config instance' );
00225             $config = ConfigFactory::getDefaultInstance()->makeConfig( 'main' );
00226         }
00227 
00228         $this->config = $config;
00229 
00230         // Add 'local' source first
00231         $this->addSource( 'local', wfScript( 'load' ) );
00232 
00233         // Add other sources
00234         $this->addSource( $config->get( 'ResourceLoaderSources' ) );
00235 
00236         // Register core modules
00237         $this->register( include "$IP/resources/Resources.php" );
00238         // Register extension modules
00239         wfRunHooks( 'ResourceLoaderRegisterModules', array( &$this ) );
00240         $this->register( $config->get( 'ResourceModules' ) );
00241 
00242         if ( $config->get( 'EnableJavaScriptTest' ) === true ) {
00243             $this->registerTestModules();
00244         }
00245 
00246         wfProfileOut( __METHOD__ );
00247     }
00248 
00252     public function getConfig() {
00253         return $this->config;
00254     }
00255 
00269     public function register( $name, $info = null ) {
00270         wfProfileIn( __METHOD__ );
00271 
00272         // Allow multiple modules to be registered in one call
00273         $registrations = is_array( $name ) ? $name : array( $name => $info );
00274         foreach ( $registrations as $name => $info ) {
00275             // Disallow duplicate registrations
00276             if ( isset( $this->moduleInfos[$name] ) ) {
00277                 wfProfileOut( __METHOD__ );
00278                 // A module has already been registered by this name
00279                 throw new MWException(
00280                     'ResourceLoader duplicate registration error. ' .
00281                     'Another module has already been registered as ' . $name
00282                 );
00283             }
00284 
00285             // Check $name for validity
00286             if ( !self::isValidModuleName( $name ) ) {
00287                 wfProfileOut( __METHOD__ );
00288                 throw new MWException( "ResourceLoader module name '$name' is invalid, "
00289                     . "see ResourceLoader::isValidModuleName()" );
00290             }
00291 
00292             // Attach module
00293             if ( $info instanceof ResourceLoaderModule ) {
00294                 $this->moduleInfos[$name] = array( 'object' => $info );
00295                 $info->setName( $name );
00296                 $this->modules[$name] = $info;
00297             } elseif ( is_array( $info ) ) {
00298                 // New calling convention
00299                 $this->moduleInfos[$name] = $info;
00300             } else {
00301                 wfProfileOut( __METHOD__ );
00302                 throw new MWException(
00303                     'ResourceLoader module info type error for module \'' . $name .
00304                     '\': expected ResourceLoaderModule or array (got: ' . gettype( $info ) . ')'
00305                 );
00306             }
00307 
00308             // Last-minute changes
00309 
00310             // Apply custom skin-defined styles to existing modules.
00311             if ( $this->isFileModule( $name ) ) {
00312                 foreach ( $this->config->get( 'ResourceModuleSkinStyles' ) as $skinName => $skinStyles ) {
00313                     // If this module already defines skinStyles for this skin, ignore $wgResourceModuleSkinStyles.
00314                     if ( isset( $this->moduleInfos[$name]['skinStyles'][$skinName] ) ) {
00315                         continue;
00316                     }
00317 
00318                     // If $name is preceded with a '+', the defined style files will be added to 'default'
00319                     // skinStyles, otherwise 'default' will be ignored as it normally would be.
00320                     if ( isset( $skinStyles[$name] ) ) {
00321                         $paths = (array)$skinStyles[$name];
00322                         $styleFiles = array();
00323                     } elseif ( isset( $skinStyles['+' . $name] ) ) {
00324                         $paths = (array)$skinStyles['+' . $name];
00325                         $styleFiles = isset( $this->moduleInfos[$name]['skinStyles']['default'] ) ?
00326                             $this->moduleInfos[$name]['skinStyles']['default'] :
00327                             array();
00328                     } else {
00329                         continue;
00330                     }
00331 
00332                     // Add new file paths, remapping them to refer to our directories and not use settings
00333                     // from the module we're modifying. These can come from the base definition or be defined
00334                     // for each module.
00335                     list( $localBasePath, $remoteBasePath ) =
00336                         ResourceLoaderFileModule::extractBasePaths( $skinStyles );
00337                     list( $localBasePath, $remoteBasePath ) =
00338                         ResourceLoaderFileModule::extractBasePaths( $paths, $localBasePath, $remoteBasePath );
00339 
00340                     foreach ( $paths as $path ) {
00341                         $styleFiles[] = new ResourceLoaderFilePath( $path, $localBasePath, $remoteBasePath );
00342                     }
00343 
00344                     $this->moduleInfos[$name]['skinStyles'][$skinName] = $styleFiles;
00345                 }
00346             }
00347         }
00348 
00349         wfProfileOut( __METHOD__ );
00350     }
00351 
00354     public function registerTestModules() {
00355         global $IP;
00356 
00357         if ( $this->config->get( 'EnableJavaScriptTest' ) !== true ) {
00358             throw new MWException( 'Attempt to register JavaScript test modules '
00359                 . 'but <code>$wgEnableJavaScriptTest</code> is false. '
00360                 . 'Edit your <code>LocalSettings.php</code> to enable it.' );
00361         }
00362 
00363         wfProfileIn( __METHOD__ );
00364 
00365         // Get core test suites
00366         $testModules = array();
00367         $testModules['qunit'] = array();
00368         // Get other test suites (e.g. from extensions)
00369         wfRunHooks( 'ResourceLoaderTestModules', array( &$testModules, &$this ) );
00370 
00371         // Add the testrunner (which configures QUnit) to the dependencies.
00372         // Since it must be ready before any of the test suites are executed.
00373         foreach ( $testModules['qunit'] as &$module ) {
00374             // Make sure all test modules are top-loading so that when QUnit starts
00375             // on document-ready, it will run once and finish. If some tests arrive
00376             // later (possibly after QUnit has already finished) they will be ignored.
00377             $module['position'] = 'top';
00378             $module['dependencies'][] = 'test.mediawiki.qunit.testrunner';
00379         }
00380 
00381         $testModules['qunit'] =
00382             ( include "$IP/tests/qunit/QUnitTestResources.php" ) + $testModules['qunit'];
00383 
00384         foreach ( $testModules as $id => $names ) {
00385             // Register test modules
00386             $this->register( $testModules[$id] );
00387 
00388             // Keep track of their names so that they can be loaded together
00389             $this->testModuleNames[$id] = array_keys( $testModules[$id] );
00390         }
00391 
00392         wfProfileOut( __METHOD__ );
00393     }
00394 
00403     public function addSource( $id, $loadUrl = null ) {
00404         // Allow multiple sources to be registered in one call
00405         if ( is_array( $id ) ) {
00406             foreach ( $id as $key => $value ) {
00407                 $this->addSource( $key, $value );
00408             }
00409             return;
00410         }
00411 
00412         // Disallow duplicates
00413         if ( isset( $this->sources[$id] ) ) {
00414             throw new MWException(
00415                 'ResourceLoader duplicate source addition error. ' .
00416                 'Another source has already been registered as ' . $id
00417             );
00418         }
00419 
00420         // Pre 1.24 backwards-compatibility
00421         if ( is_array( $loadUrl ) ) {
00422             if ( !isset( $loadUrl['loadScript'] ) ) {
00423                 throw new MWException(
00424                     __METHOD__ . ' was passed an array with no "loadScript" key.'
00425                 );
00426             }
00427 
00428             $loadUrl = $loadUrl['loadScript'];
00429         }
00430 
00431         $this->sources[$id] = $loadUrl;
00432     }
00433 
00439     public function getModuleNames() {
00440         return array_keys( $this->moduleInfos );
00441     }
00442 
00453     public function getTestModuleNames( $framework = 'all' ) {
00455         if ( $framework == 'all' ) {
00456             return $this->testModuleNames;
00457         } elseif ( isset( $this->testModuleNames[$framework] )
00458             && is_array( $this->testModuleNames[$framework] )
00459         ) {
00460             return $this->testModuleNames[$framework];
00461         } else {
00462             return array();
00463         }
00464     }
00465 
00477     public function getModule( $name ) {
00478         if ( !isset( $this->modules[$name] ) ) {
00479             if ( !isset( $this->moduleInfos[$name] ) ) {
00480                 // No such module
00481                 return null;
00482             }
00483             // Construct the requested object
00484             $info = $this->moduleInfos[$name];
00486             if ( isset( $info['object'] ) ) {
00487                 // Object given in info array
00488                 $object = $info['object'];
00489             } else {
00490                 if ( !isset( $info['class'] ) ) {
00491                     $class = 'ResourceLoaderFileModule';
00492                 } else {
00493                     $class = $info['class'];
00494                 }
00496                 $object = new $class( $info );
00497                 $object->setConfig( $this->getConfig() );
00498             }
00499             $object->setName( $name );
00500             $this->modules[$name] = $object;
00501         }
00502 
00503         return $this->modules[$name];
00504     }
00505 
00512     protected function isFileModule( $name ) {
00513         if ( !isset( $this->moduleInfos[$name] ) ) {
00514             return false;
00515         }
00516         $info = $this->moduleInfos[$name];
00517         if ( isset( $info['object'] ) || isset( $info['class'] ) ) {
00518             return false;
00519         }
00520         return true;
00521     }
00522 
00528     public function getSources() {
00529         return $this->sources;
00530     }
00531 
00541     public function getLoadScript( $source ) {
00542         if ( !isset( $this->sources[$source] ) ) {
00543             throw new MWException( "The $source source was never registered in ResourceLoader." );
00544         }
00545         return $this->sources[$source];
00546     }
00547 
00553     public function respond( ResourceLoaderContext $context ) {
00554         // Use file cache if enabled and available...
00555         if ( $this->config->get( 'UseFileCache' ) ) {
00556             $fileCache = ResourceFileCache::newFromContext( $context );
00557             if ( $this->tryRespondFromFileCache( $fileCache, $context ) ) {
00558                 return; // output handled
00559             }
00560         }
00561 
00562         // Buffer output to catch warnings. Normally we'd use ob_clean() on the
00563         // top-level output buffer to clear warnings, but that breaks when ob_gzhandler
00564         // is used: ob_clean() will clear the GZIP header in that case and it won't come
00565         // back for subsequent output, resulting in invalid GZIP. So we have to wrap
00566         // the whole thing in our own output buffer to be sure the active buffer
00567         // doesn't use ob_gzhandler.
00568         // See http://bugs.php.net/bug.php?id=36514
00569         ob_start();
00570 
00571         wfProfileIn( __METHOD__ );
00572         $errors = '';
00573 
00574         // Find out which modules are missing and instantiate the others
00575         $modules = array();
00576         $missing = array();
00577         foreach ( $context->getModules() as $name ) {
00578             $module = $this->getModule( $name );
00579             if ( $module ) {
00580                 // Do not allow private modules to be loaded from the web.
00581                 // This is a security issue, see bug 34907.
00582                 if ( $module->getGroup() === 'private' ) {
00583                     wfDebugLog( 'resourceloader', __METHOD__ . ": request for private module '$name' denied" );
00584                     $this->hasErrors = true;
00585                     // Add exception to the output as a comment
00586                     $errors .= self::makeComment( "Cannot show private module \"$name\"" );
00587 
00588                     continue;
00589                 }
00590                 $modules[$name] = $module;
00591             } else {
00592                 $missing[] = $name;
00593             }
00594         }
00595 
00596         // Preload information needed to the mtime calculation below
00597         try {
00598             $this->preloadModuleInfo( array_keys( $modules ), $context );
00599         } catch ( Exception $e ) {
00600             MWExceptionHandler::logException( $e );
00601             wfDebugLog( 'resourceloader', __METHOD__ . ": preloading module info failed: $e" );
00602             $this->hasErrors = true;
00603             // Add exception to the output as a comment
00604             $errors .= self::formatException( $e );
00605         }
00606 
00607         wfProfileIn( __METHOD__ . '-getModifiedTime' );
00608 
00609         // To send Last-Modified and support If-Modified-Since, we need to detect
00610         // the last modified time
00611         $mtime = wfTimestamp( TS_UNIX, $this->config->get( 'CacheEpoch' ) );
00612         foreach ( $modules as $module ) {
00616             try {
00617                 // Calculate maximum modified time
00618                 $mtime = max( $mtime, $module->getModifiedTime( $context ) );
00619             } catch ( Exception $e ) {
00620                 MWExceptionHandler::logException( $e );
00621                 wfDebugLog( 'resourceloader', __METHOD__ . ": calculating maximum modified time failed: $e" );
00622                 $this->hasErrors = true;
00623                 // Add exception to the output as a comment
00624                 $errors .= self::formatException( $e );
00625             }
00626         }
00627 
00628         wfProfileOut( __METHOD__ . '-getModifiedTime' );
00629 
00630         // If there's an If-Modified-Since header, respond with a 304 appropriately
00631         if ( $this->tryRespondLastModified( $context, $mtime ) ) {
00632             wfProfileOut( __METHOD__ );
00633             return; // output handled (buffers cleared)
00634         }
00635 
00636         // Generate a response
00637         $response = $this->makeModuleResponse( $context, $modules, $missing );
00638 
00639         // Prepend comments indicating exceptions
00640         $response = $errors . $response;
00641 
00642         // Capture any PHP warnings from the output buffer and append them to the
00643         // response in a comment if we're in debug mode.
00644         if ( $context->getDebug() && strlen( $warnings = ob_get_contents() ) ) {
00645             $response = self::makeComment( $warnings ) . $response;
00646             $this->hasErrors = true;
00647         }
00648 
00649         // Save response to file cache unless there are errors
00650         if ( isset( $fileCache ) && !$errors && !count( $missing ) ) {
00651             // Cache single modules...and other requests if there are enough hits
00652             if ( ResourceFileCache::useFileCache( $context ) ) {
00653                 if ( $fileCache->isCacheWorthy() ) {
00654                     $fileCache->saveText( $response );
00655                 } else {
00656                     $fileCache->incrMissesRecent( $context->getRequest() );
00657                 }
00658             }
00659         }
00660 
00661         // Send content type and cache related headers
00662         $this->sendResponseHeaders( $context, $mtime, $this->hasErrors );
00663 
00664         // Remove the output buffer and output the response
00665         ob_end_clean();
00666         echo $response;
00667 
00668         wfProfileOut( __METHOD__ );
00669     }
00670 
00678     protected function sendResponseHeaders( ResourceLoaderContext $context, $mtime, $errors ) {
00679         $rlMaxage = $this->config->get( 'ResourceLoaderMaxage' );
00680         // If a version wasn't specified we need a shorter expiry time for updates
00681         // to propagate to clients quickly
00682         // If there were errors, we also need a shorter expiry time so we can recover quickly
00683         if ( is_null( $context->getVersion() ) || $errors ) {
00684             $maxage = $rlMaxage['unversioned']['client'];
00685             $smaxage = $rlMaxage['unversioned']['server'];
00686         // If a version was specified we can use a longer expiry time since changing
00687         // version numbers causes cache misses
00688         } else {
00689             $maxage = $rlMaxage['versioned']['client'];
00690             $smaxage = $rlMaxage['versioned']['server'];
00691         }
00692         if ( $context->getOnly() === 'styles' ) {
00693             header( 'Content-Type: text/css; charset=utf-8' );
00694             header( 'Access-Control-Allow-Origin: *' );
00695         } else {
00696             header( 'Content-Type: text/javascript; charset=utf-8' );
00697         }
00698         header( 'Last-Modified: ' . wfTimestamp( TS_RFC2822, $mtime ) );
00699         if ( $context->getDebug() ) {
00700             // Do not cache debug responses
00701             header( 'Cache-Control: private, no-cache, must-revalidate' );
00702             header( 'Pragma: no-cache' );
00703         } else {
00704             header( "Cache-Control: public, max-age=$maxage, s-maxage=$smaxage" );
00705             $exp = min( $maxage, $smaxage );
00706             header( 'Expires: ' . wfTimestamp( TS_RFC2822, $exp + time() ) );
00707         }
00708     }
00709 
00720     protected function tryRespondLastModified( ResourceLoaderContext $context, $mtime ) {
00721         // If there's an If-Modified-Since header, respond with a 304 appropriately
00722         // Some clients send "timestamp;length=123". Strip the part after the first ';'
00723         // so we get a valid timestamp.
00724         $ims = $context->getRequest()->getHeader( 'If-Modified-Since' );
00725         // Never send 304s in debug mode
00726         if ( $ims !== false && !$context->getDebug() ) {
00727             $imsTS = strtok( $ims, ';' );
00728             if ( $mtime <= wfTimestamp( TS_UNIX, $imsTS ) ) {
00729                 // There's another bug in ob_gzhandler (see also the comment at
00730                 // the top of this function) that causes it to gzip even empty
00731                 // responses, meaning it's impossible to produce a truly empty
00732                 // response (because the gzip header is always there). This is
00733                 // a problem because 304 responses have to be completely empty
00734                 // per the HTTP spec, and Firefox behaves buggily when they're not.
00735                 // See also http://bugs.php.net/bug.php?id=51579
00736                 // To work around this, we tear down all output buffering before
00737                 // sending the 304.
00738                 wfResetOutputBuffers( /* $resetGzipEncoding = */ true );
00739 
00740                 header( 'HTTP/1.0 304 Not Modified' );
00741                 header( 'Status: 304 Not Modified' );
00742                 return true;
00743             }
00744         }
00745         return false;
00746     }
00747 
00755     protected function tryRespondFromFileCache(
00756         ResourceFileCache $fileCache, ResourceLoaderContext $context
00757     ) {
00758         $rlMaxage = $this->config->get( 'ResourceLoaderMaxage' );
00759         // Buffer output to catch warnings.
00760         ob_start();
00761         // Get the maximum age the cache can be
00762         $maxage = is_null( $context->getVersion() )
00763             ? $rlMaxage['unversioned']['server']
00764             : $rlMaxage['versioned']['server'];
00765         // Minimum timestamp the cache file must have
00766         $good = $fileCache->isCacheGood( wfTimestamp( TS_MW, time() - $maxage ) );
00767         if ( !$good ) {
00768             try { // RL always hits the DB on file cache miss...
00769                 wfGetDB( DB_SLAVE );
00770             } catch ( DBConnectionError $e ) { // ...check if we need to fallback to cache
00771                 $good = $fileCache->isCacheGood(); // cache existence check
00772             }
00773         }
00774         if ( $good ) {
00775             $ts = $fileCache->cacheTimestamp();
00776             // Send content type and cache headers
00777             $this->sendResponseHeaders( $context, $ts, false );
00778             // If there's an If-Modified-Since header, respond with a 304 appropriately
00779             if ( $this->tryRespondLastModified( $context, $ts ) ) {
00780                 return false; // output handled (buffers cleared)
00781             }
00782             $response = $fileCache->fetchText();
00783             // Capture any PHP warnings from the output buffer and append them to the
00784             // response in a comment if we're in debug mode.
00785             if ( $context->getDebug() && strlen( $warnings = ob_get_contents() ) ) {
00786                 $response = "/*\n$warnings\n*/\n" . $response;
00787             }
00788             // Remove the output buffer and output the response
00789             ob_end_clean();
00790             echo $response . "\n/* Cached {$ts} */";
00791             return true; // cache hit
00792         }
00793         // Clear buffer
00794         ob_end_clean();
00795 
00796         return false; // cache miss
00797     }
00798 
00807     public static function makeComment( $text ) {
00808         $encText = str_replace( '*/', '* /', $text );
00809         return "/*\n$encText\n*/\n";
00810     }
00811 
00818     public static function formatException( $e ) {
00819         global $wgShowExceptionDetails;
00820 
00821         if ( $wgShowExceptionDetails ) {
00822             return self::makeComment( $e->__toString() );
00823         } else {
00824             return self::makeComment( wfMessage( 'internalerror' )->text() );
00825         }
00826     }
00827 
00836     public function makeModuleResponse( ResourceLoaderContext $context,
00837         array $modules, array $missing = array()
00838     ) {
00839         $out = '';
00840         $exceptions = '';
00841         $states = array();
00842 
00843         if ( !count( $modules ) && !count( $missing ) ) {
00844             return "/* This file is the Web entry point for MediaWiki's ResourceLoader:
00845    <https://www.mediawiki.org/wiki/ResourceLoader>. In this request,
00846    no modules were requested. Max made me put this here. */";
00847         }
00848 
00849         wfProfileIn( __METHOD__ );
00850 
00851         // Pre-fetch blobs
00852         if ( $context->shouldIncludeMessages() ) {
00853             try {
00854                 $blobs = MessageBlobStore::getInstance()->get( $this, $modules, $context->getLanguage() );
00855             } catch ( Exception $e ) {
00856                 MWExceptionHandler::logException( $e );
00857                 wfDebugLog(
00858                     'resourceloader',
00859                     __METHOD__ . ": pre-fetching blobs from MessageBlobStore failed: $e"
00860                 );
00861                 $this->hasErrors = true;
00862                 // Add exception to the output as a comment
00863                 $exceptions .= self::formatException( $e );
00864             }
00865         } else {
00866             $blobs = array();
00867         }
00868 
00869         foreach ( $missing as $name ) {
00870             $states[$name] = 'missing';
00871         }
00872 
00873         // Generate output
00874         $isRaw = false;
00875         foreach ( $modules as $name => $module ) {
00880             wfProfileIn( __METHOD__ . '-' . $name );
00881             try {
00882                 $scripts = '';
00883                 if ( $context->shouldIncludeScripts() ) {
00884                     // If we are in debug mode, we'll want to return an array of URLs if possible
00885                     // However, we can't do this if the module doesn't support it
00886                     // We also can't do this if there is an only= parameter, because we have to give
00887                     // the module a way to return a load.php URL without causing an infinite loop
00888                     if ( $context->getDebug() && !$context->getOnly() && $module->supportsURLLoading() ) {
00889                         $scripts = $module->getScriptURLsForDebug( $context );
00890                     } else {
00891                         $scripts = $module->getScript( $context );
00892                         // rtrim() because there are usually a few line breaks
00893                         // after the last ';'. A new line at EOF, a new line
00894                         // added by ResourceLoaderFileModule::readScriptFiles, etc.
00895                         if ( is_string( $scripts )
00896                             && strlen( $scripts )
00897                             && substr( rtrim( $scripts ), -1 ) !== ';'
00898                         ) {
00899                             // Append semicolon to prevent weird bugs caused by files not
00900                             // terminating their statements right (bug 27054)
00901                             $scripts .= ";\n";
00902                         }
00903                     }
00904                 }
00905                 // Styles
00906                 $styles = array();
00907                 if ( $context->shouldIncludeStyles() ) {
00908                     // Don't create empty stylesheets like array( '' => '' ) for modules
00909                     // that don't *have* any stylesheets (bug 38024).
00910                     $stylePairs = $module->getStyles( $context );
00911                     if ( count( $stylePairs ) ) {
00912                         // If we are in debug mode without &only= set, we'll want to return an array of URLs
00913                         // See comment near shouldIncludeScripts() for more details
00914                         if ( $context->getDebug() && !$context->getOnly() && $module->supportsURLLoading() ) {
00915                             $styles = array(
00916                                 'url' => $module->getStyleURLsForDebug( $context )
00917                             );
00918                         } else {
00919                             // Minify CSS before embedding in mw.loader.implement call
00920                             // (unless in debug mode)
00921                             if ( !$context->getDebug() ) {
00922                                 foreach ( $stylePairs as $media => $style ) {
00923                                     // Can be either a string or an array of strings.
00924                                     if ( is_array( $style ) ) {
00925                                         $stylePairs[$media] = array();
00926                                         foreach ( $style as $cssText ) {
00927                                             if ( is_string( $cssText ) ) {
00928                                                 $stylePairs[$media][] = $this->filter( 'minify-css', $cssText );
00929                                             }
00930                                         }
00931                                     } elseif ( is_string( $style ) ) {
00932                                         $stylePairs[$media] = $this->filter( 'minify-css', $style );
00933                                     }
00934                                 }
00935                             }
00936                             // Wrap styles into @media groups as needed and flatten into a numerical array
00937                             $styles = array(
00938                                 'css' => self::makeCombinedStyles( $stylePairs )
00939                             );
00940                         }
00941                     }
00942                 }
00943 
00944                 // Messages
00945                 $messagesBlob = isset( $blobs[$name] ) ? $blobs[$name] : '{}';
00946 
00947                 // Append output
00948                 switch ( $context->getOnly() ) {
00949                     case 'scripts':
00950                         if ( is_string( $scripts ) ) {
00951                             // Load scripts raw...
00952                             $out .= $scripts;
00953                         } elseif ( is_array( $scripts ) ) {
00954                             // ...except when $scripts is an array of URLs
00955                             $out .= self::makeLoaderImplementScript( $name, $scripts, array(), array() );
00956                         }
00957                         break;
00958                     case 'styles':
00959                         // We no longer seperate into media, they are all combined now with
00960                         // custom media type groups into @media .. {} sections as part of the css string.
00961                         // Module returns either an empty array or a numerical array with css strings.
00962                         $out .= isset( $styles['css'] ) ? implode( '', $styles['css'] ) : '';
00963                         break;
00964                     case 'messages':
00965                         $out .= self::makeMessageSetScript( new XmlJsCode( $messagesBlob ) );
00966                         break;
00967                     default:
00968                         $out .= self::makeLoaderImplementScript(
00969                             $name,
00970                             $scripts,
00971                             $styles,
00972                             new XmlJsCode( $messagesBlob )
00973                         );
00974                         break;
00975                 }
00976             } catch ( Exception $e ) {
00977                 MWExceptionHandler::logException( $e );
00978                 wfDebugLog( 'resourceloader', __METHOD__ . ": generating module package failed: $e" );
00979                 $this->hasErrors = true;
00980                 // Add exception to the output as a comment
00981                 $exceptions .= self::formatException( $e );
00982 
00983                 // Respond to client with error-state instead of module implementation
00984                 $states[$name] = 'error';
00985                 unset( $modules[$name] );
00986             }
00987             $isRaw |= $module->isRaw();
00988             wfProfileOut( __METHOD__ . '-' . $name );
00989         }
00990 
00991         // Update module states
00992         if ( $context->shouldIncludeScripts() && !$context->getRaw() && !$isRaw ) {
00993             if ( count( $modules ) && $context->getOnly() === 'scripts' ) {
00994                 // Set the state of modules loaded as only scripts to ready as
00995                 // they don't have an mw.loader.implement wrapper that sets the state
00996                 foreach ( $modules as $name => $module ) {
00997                     $states[$name] = 'ready';
00998                 }
00999             }
01000 
01001             // Set the state of modules we didn't respond to with mw.loader.implement
01002             if ( count( $states ) ) {
01003                 $out .= self::makeLoaderStateScript( $states );
01004             }
01005         } else {
01006             if ( count( $states ) ) {
01007                 $exceptions .= self::makeComment(
01008                     'Problematic modules: ' . FormatJson::encode( $states, ResourceLoader::inDebugMode() )
01009                 );
01010             }
01011         }
01012 
01013         if ( !$context->getDebug() ) {
01014             if ( $context->getOnly() === 'styles' ) {
01015                 $out = $this->filter( 'minify-css', $out );
01016             } else {
01017                 $out = $this->filter( 'minify-js', $out );
01018             }
01019         }
01020 
01021         wfProfileOut( __METHOD__ );
01022         return $exceptions . $out;
01023     }
01024 
01025     /* Static Methods */
01026 
01040     public static function makeLoaderImplementScript( $name, $scripts, $styles, $messages ) {
01041         if ( is_string( $scripts ) ) {
01042             $scripts = new XmlJsCode( "function ( $, jQuery ) {\n{$scripts}\n}" );
01043         } elseif ( !is_array( $scripts ) ) {
01044             throw new MWException( 'Invalid scripts error. Array of URLs or string of code expected.' );
01045         }
01046         return Xml::encodeJsCall(
01047             'mw.loader.implement',
01048             array(
01049                 $name,
01050                 $scripts,
01051                 // Force objects. mw.loader.implement requires them to be javascript objects.
01052                 // Although these variables are associative arrays, which become javascript
01053                 // objects through json_encode. In many cases they will be empty arrays, and
01054                 // PHP/json_encode() consider empty arrays to be numerical arrays and
01055                 // output javascript "[]" instead of "{}". This fixes that.
01056                 (object)$styles,
01057                 (object)$messages
01058             ),
01059             ResourceLoader::inDebugMode()
01060         );
01061     }
01062 
01070     public static function makeMessageSetScript( $messages ) {
01071         return Xml::encodeJsCall(
01072             'mw.messages.set',
01073             array( (object)$messages ),
01074             ResourceLoader::inDebugMode()
01075         );
01076     }
01077 
01085     public static function makeCombinedStyles( array $stylePairs ) {
01086         $out = array();
01087         foreach ( $stylePairs as $media => $styles ) {
01088             // ResourceLoaderFileModule::getStyle can return the styles
01089             // as a string or an array of strings. This is to allow separation in
01090             // the front-end.
01091             $styles = (array)$styles;
01092             foreach ( $styles as $style ) {
01093                 $style = trim( $style );
01094                 // Don't output an empty "@media print { }" block (bug 40498)
01095                 if ( $style !== '' ) {
01096                     // Transform the media type based on request params and config
01097                     // The way that this relies on $wgRequest to propagate request params is slightly evil
01098                     $media = OutputPage::transformCssMedia( $media );
01099 
01100                     if ( $media === '' || $media == 'all' ) {
01101                         $out[] = $style;
01102                     } elseif ( is_string( $media ) ) {
01103                         $out[] = "@media $media {\n" . str_replace( "\n", "\n\t", "\t" . $style ) . "}";
01104                     }
01105                     // else: skip
01106                 }
01107             }
01108         }
01109         return $out;
01110     }
01111 
01126     public static function makeLoaderStateScript( $name, $state = null ) {
01127         if ( is_array( $name ) ) {
01128             return Xml::encodeJsCall(
01129                 'mw.loader.state',
01130                 array( $name ),
01131                 ResourceLoader::inDebugMode()
01132             );
01133         } else {
01134             return Xml::encodeJsCall(
01135                 'mw.loader.state',
01136                 array( $name, $state ),
01137                 ResourceLoader::inDebugMode()
01138             );
01139         }
01140     }
01141 
01156     public static function makeCustomLoaderScript( $name, $version, $dependencies,
01157         $group, $source, $script
01158     ) {
01159         $script = str_replace( "\n", "\n\t", trim( $script ) );
01160         return Xml::encodeJsCall(
01161             "( function ( name, version, dependencies, group, source ) {\n\t$script\n} )",
01162             array( $name, $version, $dependencies, $group, $source ),
01163             ResourceLoader::inDebugMode()
01164         );
01165     }
01166 
01194     public static function makeLoaderRegisterScript( $name, $version = null,
01195         $dependencies = null, $group = null, $source = null, $skip = null
01196     ) {
01197         if ( is_array( $name ) ) {
01198             return Xml::encodeJsCall(
01199                 'mw.loader.register',
01200                 array( $name ),
01201                 ResourceLoader::inDebugMode()
01202             );
01203         } else {
01204             $version = (int)$version > 1 ? (int)$version : 1;
01205             return Xml::encodeJsCall(
01206                 'mw.loader.register',
01207                 array( $name, $version, $dependencies, $group, $source, $skip ),
01208                 ResourceLoader::inDebugMode()
01209             );
01210         }
01211     }
01212 
01227     public static function makeLoaderSourcesScript( $id, $properties = null ) {
01228         if ( is_array( $id ) ) {
01229             return Xml::encodeJsCall(
01230                 'mw.loader.addSource',
01231                 array( $id ),
01232                 ResourceLoader::inDebugMode()
01233             );
01234         } else {
01235             return Xml::encodeJsCall(
01236                 'mw.loader.addSource',
01237                 array( $id, $properties ),
01238                 ResourceLoader::inDebugMode()
01239             );
01240         }
01241     }
01242 
01250     public static function makeLoaderConditionalScript( $script ) {
01251         return "if(window.mw){\n" . trim( $script ) . "\n}";
01252     }
01253 
01261     public static function makeConfigSetScript( array $configuration ) {
01262         return Xml::encodeJsCall(
01263             'mw.config.set',
01264             array( $configuration ),
01265             ResourceLoader::inDebugMode()
01266         );
01267     }
01268 
01277     public static function makePackedModulesString( $modules ) {
01278         $groups = array(); // array( prefix => array( suffixes ) )
01279         foreach ( $modules as $module ) {
01280             $pos = strrpos( $module, '.' );
01281             $prefix = $pos === false ? '' : substr( $module, 0, $pos );
01282             $suffix = $pos === false ? $module : substr( $module, $pos + 1 );
01283             $groups[$prefix][] = $suffix;
01284         }
01285 
01286         $arr = array();
01287         foreach ( $groups as $prefix => $suffixes ) {
01288             $p = $prefix === '' ? '' : $prefix . '.';
01289             $arr[] = $p . implode( ',', $suffixes );
01290         }
01291         $str = implode( '|', $arr );
01292         return $str;
01293     }
01294 
01300     public static function inDebugMode() {
01301         if ( self::$debugMode === null ) {
01302             global $wgRequest, $wgResourceLoaderDebug;
01303             self::$debugMode = $wgRequest->getFuzzyBool( 'debug',
01304                 $wgRequest->getCookie( 'resourceLoaderDebug', '', $wgResourceLoaderDebug )
01305             );
01306         }
01307         return self::$debugMode;
01308     }
01309 
01317     public static function clearCache() {
01318         self::$debugMode = null;
01319     }
01320 
01330     public function createLoaderURL( $source, ResourceLoaderContext $context,
01331         $extraQuery = array()
01332     ) {
01333         $query = self::createLoaderQuery( $context, $extraQuery );
01334         $script = $this->getLoadScript( $source );
01335 
01336         // Prevent the IE6 extension check from being triggered (bug 28840)
01337         // by appending a character that's invalid in Windows extensions ('*')
01338         return wfExpandUrl( wfAppendQuery( $script, $query ) . '&*', PROTO_RELATIVE );
01339     }
01340 
01356     public static function makeLoaderURL( $modules, $lang, $skin, $user = null,
01357         $version = null, $debug = false, $only = null, $printable = false,
01358         $handheld = false, $extraQuery = array()
01359     ) {
01360         global $wgLoadScript;
01361 
01362         $query = self::makeLoaderQuery( $modules, $lang, $skin, $user, $version, $debug,
01363             $only, $printable, $handheld, $extraQuery
01364         );
01365 
01366         // Prevent the IE6 extension check from being triggered (bug 28840)
01367         // by appending a character that's invalid in Windows extensions ('*')
01368         return wfExpandUrl( wfAppendQuery( $wgLoadScript, $query ) . '&*', PROTO_RELATIVE );
01369     }
01370 
01380     public static function createLoaderQuery( ResourceLoaderContext $context, $extraQuery = array() ) {
01381         return self::makeLoaderQuery(
01382             $context->getModules(),
01383             $context->getLanguage(),
01384             $context->getSkin(),
01385             $context->getUser(),
01386             $context->getVersion(),
01387             $context->getDebug(),
01388             $context->getOnly(),
01389             $context->getRequest()->getBool( 'printable' ),
01390             $context->getRequest()->getBool( 'handheld' ),
01391             $extraQuery
01392         );
01393     }
01394 
01412     public static function makeLoaderQuery( $modules, $lang, $skin, $user = null,
01413         $version = null, $debug = false, $only = null, $printable = false,
01414         $handheld = false, $extraQuery = array()
01415     ) {
01416         $query = array(
01417             'modules' => self::makePackedModulesString( $modules ),
01418             'lang' => $lang,
01419             'skin' => $skin,
01420             'debug' => $debug ? 'true' : 'false',
01421         );
01422         if ( $user !== null ) {
01423             $query['user'] = $user;
01424         }
01425         if ( $version !== null ) {
01426             $query['version'] = $version;
01427         }
01428         if ( $only !== null ) {
01429             $query['only'] = $only;
01430         }
01431         if ( $printable ) {
01432             $query['printable'] = 1;
01433         }
01434         if ( $handheld ) {
01435             $query['handheld'] = 1;
01436         }
01437         $query += $extraQuery;
01438 
01439         // Make queries uniform in order
01440         ksort( $query );
01441         return $query;
01442     }
01443 
01453     public static function isValidModuleName( $moduleName ) {
01454         return !preg_match( '/[|,!]/', $moduleName ) && strlen( $moduleName ) <= 255;
01455     }
01456 
01465     public static function getLessCompiler( Config $config ) {
01466         // When called from the installer, it is possible that a required PHP extension
01467         // is missing (at least for now; see bug 47564). If this is the case, throw an
01468         // exception (caught by the installer) to prevent a fatal error later on.
01469         if ( !function_exists( 'ctype_digit' ) ) {
01470             throw new MWException( 'lessc requires the Ctype extension' );
01471         }
01472 
01473         $less = new lessc();
01474         $less->setPreserveComments( true );
01475         $less->setVariables( self::getLessVars( $config ) );
01476         $less->setImportDir( $config->get( 'ResourceLoaderLESSImportPaths' ) );
01477         foreach ( $config->get( 'ResourceLoaderLESSFunctions' ) as $name => $func ) {
01478             $less->registerFunction( $name, $func );
01479         }
01480         return $less;
01481     }
01482 
01490     public static function getLessVars( Config $config ) {
01491         $lessVars = $config->get( 'ResourceLoaderLESSVars' );
01492         // Sort by key to ensure consistent hashing for cache lookups.
01493         ksort( $lessVars );
01494         return $lessVars;
01495     }
01496 }