1 <?php
  2 
  3 /**
  4  * REDAXO Autoloader.
  5  *
  6  * This class was originally copied from the Symfony Framework:
  7  * Fabien Potencier <fabien.potencier@symfony-project.com>
  8  *
  9  * Adjusted in very many places
 10  *
 11  * @package redaxo\core
 12  */
 13 class rex_autoload
 14 {
 15     /**
 16      * @var Composer\Autoload\ClassLoader
 17      */
 18     protected static $composerLoader;
 19 
 20     protected static $registered = false;
 21     protected static $cacheFile = null;
 22     protected static $cacheChanged = false;
 23     protected static $reloaded = false;
 24     protected static $dirs = [];
 25     protected static $addedDirs = [];
 26     protected static $classes = [];
 27 
 28     /**
 29      * Register rex_autoload in spl autoloader.
 30      */
 31     public static function register()
 32     {
 33         if (self::$registered) {
 34             return;
 35         }
 36 
 37         ini_set('unserialize_callback_func', 'spl_autoload_call');
 38 
 39         if (!self::$composerLoader) {
 40             self::$composerLoader = require rex_path::core('vendor/autoload.php');
 41             // Unregister Composer Autoloader because we call self::$composerLoader->loadClass() manually
 42             self::$composerLoader->unregister();
 43             // fast exit when classes cannot be found in the classmap
 44             self::$composerLoader->setClassMapAuthoritative(true);
 45         }
 46 
 47         if (false === spl_autoload_register([self::class, 'autoload'])) {
 48             throw new Exception(sprintf('Unable to register %s::autoload as an autoloading method.', self::class));
 49         }
 50 
 51         self::$cacheFile = rex_path::coreCache('autoload.cache');
 52         self::loadCache();
 53         register_shutdown_function([self::class, 'saveCache']);
 54 
 55         self::$registered = true;
 56     }
 57 
 58     /**
 59      * Unregister rex_autoload from spl autoloader.
 60      */
 61     public static function unregister()
 62     {
 63         spl_autoload_unregister([self::class, 'autoload']);
 64         self::$registered = false;
 65     }
 66 
 67     /**
 68      * Handles autoloading of classes.
 69      *
 70      * @param string $class A class name
 71      *
 72      * @return bool Returns true if the class has been loaded
 73      */
 74     public static function autoload($class)
 75     {
 76         // class already exists
 77         if (self::classExists($class)) {
 78             return true;
 79         }
 80 
 81         $force = false;
 82         $lowerClass = strtolower($class);
 83         if (isset(self::$classes[$lowerClass])) {
 84             $path = rex_path::base(self::$classes[$lowerClass]);
 85             // we have a class path for the class, let's include it
 86             if (@include_once $path) {
 87                 if (self::classExists($class)) {
 88                     return true;
 89                 }
 90             }
 91             // there is a class path in cache, but the file does not exist or does not contain the class any more
 92             // but maybe the class exists in another already known file now
 93             // so all files have to be analysed again => $force reload
 94             $force = true;
 95             unset(self::$classes[$lowerClass]);
 96             self::$cacheChanged = true;
 97         }
 98 
 99         // Return true if class exists after calling $composerLoader
100         if (self::$composerLoader->loadClass($class) && self::classExists($class)) {
101             return true;
102         }
103 
104         // Class not found, so reanalyse all directories if not already done or if $force==true
105         // but only if an admin is logged in
106         if (
107             (!self::$reloaded || $force) &&
108             (rex::isSetup() || rex::getConsole() || rex::isDebugMode() || ($user = rex_backend_login::createUser()) && $user->isAdmin())
109         ) {
110             self::reload($force);
111             return self::autoload($class);
112         }
113 
114         return false;
115     }
116 
117     /**
118      * Returns whether the given class/interface/trait exists.
119      *
120      * @param string $class
121      *
122      * @return bool
123      */
124     private static function classExists($class)
125     {
126         return class_exists($class, false) || interface_exists($class, false) || trait_exists($class, false);
127     }
128 
129     /**
130      * Loads the cache.
131      */
132     private static function loadCache()
133     {
134         if (!self::$cacheFile || !($cache = @file_get_contents(self::$cacheFile))) {
135             return;
136         }
137 
138         list(self::$classes, self::$dirs) = json_decode($cache, true);
139     }
140 
141     /**
142      * Saves the cache.
143      */
144     public static function saveCache()
145     {
146         if (!self::$cacheChanged) {
147             return;
148         }
149 
150         // dont persist a possible incomplete cache, because requests of end-users (which are not allowed to regenerate a existing cache)
151         // can error in some crazy class-not-found errors which are hard to debug.
152         $error = error_get_last();
153         if (is_array($error) && in_array($error['type'], [E_USER_ERROR, E_ERROR, E_COMPILE_ERROR, E_RECOVERABLE_ERROR, E_PARSE])) {
154             return;
155         }
156 
157         // remove obsolete dirs from cache
158         foreach (self::$dirs as $dir => $files) {
159             if (!in_array($dir, self::$addedDirs)) {
160                 unset(self::$dirs[$dir]);
161             }
162         }
163 
164         if (!rex_file::putCache(self::$cacheFile, [self::$classes, self::$dirs])) {
165             throw new Exception("Unable to write autoload cachefile '" . self::$cacheFile . "'!");
166         }
167         self::$cacheChanged = false;
168     }
169 
170     /**
171      * Reanalyses all added directories.
172      *
173      * @param bool $force If true, all files are reanalysed, otherwise only new and changed files
174      */
175     public static function reload($force = false)
176     {
177         if ($force) {
178             self::$classes = [];
179             self::$dirs = [];
180         }
181         foreach (self::$addedDirs as $dir) {
182             self::_addDirectory($dir);
183         }
184         self::$reloaded = true;
185     }
186 
187     /**
188      * Removes the cache.
189      */
190     public static function removeCache()
191     {
192         rex_file::delete(self::$cacheFile);
193     }
194 
195     /**
196      * Adds a directory to the autoloading system if not yet present.
197      *
198      * @param string $dir The directory to look for classes
199      */
200     public static function addDirectory($dir)
201     {
202         $dir = rtrim($dir, '/\\') . DIRECTORY_SEPARATOR;
203         $dir = rex_path::relative($dir);
204         if (in_array($dir, self::$addedDirs)) {
205             return;
206         }
207         self::$addedDirs[] = $dir;
208         if (!isset(self::$dirs[$dir])) {
209             self::_addDirectory($dir);
210             self::$cacheChanged = true;
211         }
212     }
213 
214     /**
215      * Returns the classes.
216      *
217      * @return string[]
218      */
219     public static function getClasses()
220     {
221         return array_keys(self::$classes);
222     }
223 
224     /**
225      * @param string $dir
226      */
227     private static function _addDirectory($dir)
228     {
229         $dirPath = rex_path::base($dir);
230 
231         if (!is_dir($dirPath)) {
232             return;
233         }
234 
235         if (!isset(self::$dirs[$dir])) {
236             self::$dirs[$dir] = [];
237         }
238         $files = self::$dirs[$dir];
239         $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dirPath, RecursiveDirectoryIterator::SKIP_DOTS));
240         foreach ($iterator as $path => $file) {
241             /** @var SplFileInfo $file */
242             if (!$file->isFile() || !in_array($file->getExtension(), ['php', 'inc'])) {
243                 continue;
244             }
245 
246             $file = rex_path::relative($path);
247             unset($files[$file]);
248             $checksum = filemtime($path);
249             if (isset(self::$dirs[$dir][$file]) && self::$dirs[$dir][$file] === $checksum) {
250                 continue;
251             }
252             self::$dirs[$dir][$file] = $checksum;
253             self::$cacheChanged = true;
254 
255             $classes = self::findClasses($path);
256             foreach ($classes as $class) {
257                 $class = strtolower($class);
258                 if (!isset(self::$classes[$class])) {
259                     self::$classes[$class] = $file;
260                 }
261             }
262         }
263         foreach ($files as $file) {
264             unset(self::$dirs[$file]);
265             self::$cacheChanged = true;
266         }
267     }
268 
269     /**
270      * Extract the classes in the given file.
271      *
272      * The method is copied from Composer (with little changes):
273      * https://github.com/composer/composer/blob/f24fcea35b4e8438caa96baccec7ff932c4ac0c3/src/Composer/Autoload/ClassMapGenerator.php#L131
274      *
275      * @param string $path The file to check
276      *
277      * @throws \RuntimeException
278      *
279      * @return array The found classes
280      */
281     private static function findClasses($path)
282     {
283         $extraTypes = PHP_VERSION_ID < 50400 ? '' : '|trait';
284         if (defined('HHVM_VERSION') && version_compare(HHVM_VERSION, '3.3', '>=')) {
285             $extraTypes .= '|enum';
286         }
287 
288         // Use @ here instead of Silencer to actively suppress 'unhelpful' output
289         // @link https://github.com/composer/composer/pull/4886
290         $contents = @php_strip_whitespace($path);
291         if (!$contents) {
292             if (!file_exists($path)) {
293                 $message = 'File at "%s" does not exist, check your classmap definitions';
294             } elseif (!is_readable($path)) {
295                 $message = 'File at "%s" is not readable, check its permissions';
296             } elseif ('' === trim(file_get_contents($path))) {
297                 // The input file was really empty and thus contains no classes
298                 return [];
299             } else {
300                 $message = 'File at "%s" could not be parsed as PHP, it may be binary or corrupted';
301             }
302             $error = error_get_last();
303             if (isset($error['message'])) {
304                 $message .= PHP_EOL . 'The following message may be helpful:' . PHP_EOL . $error['message'];
305             }
306             throw new \RuntimeException(sprintf($message, $path));
307         }
308 
309         // return early if there is no chance of matching anything in this file
310         if (!preg_match('{\b(?:class|interface'.$extraTypes.')\s}i', $contents)) {
311             return [];
312         }
313 
314         // strip heredocs/nowdocs
315         $contents = preg_replace('{<<<\s*(\'?)(\w+)\\1(?:\r\n|\n|\r)(?:.*?)(?:\r\n|\n|\r)\\2(?=\r\n|\n|\r|;)}s', 'null', $contents);
316         // strip strings
317         $contents = preg_replace('{"[^"\\\\]*+(\\\\.[^"\\\\]*+)*+"|\'[^\'\\\\]*+(\\\\.[^\'\\\\]*+)*+\'}s', 'null', $contents);
318         // strip leading non-php code if needed
319         if (substr($contents, 0, 2) !== '<?') {
320             $contents = preg_replace('{^.+?<\?}s', '<?', $contents, 1, $replacements);
321             if ($replacements === 0) {
322                 return [];
323             }
324         }
325         // strip non-php blocks in the file
326         $contents = preg_replace('{\?>.+<\?}s', '?><?', $contents);
327         // strip trailing non-php code if needed
328         $pos = strrpos($contents, '?>');
329         if (false !== $pos && false === strpos(substr($contents, $pos), '<?')) {
330             $contents = substr($contents, 0, $pos);
331         }
332         // strip comments if short open tags are in the file
333         if (preg_match('{(<\?)(?!(php|hh))}i', $contents)) {
334             $contents = preg_replace('{//.* | /\*(?:[^*]++|\*(?!/))*\*/}x', '', $contents);
335         }
336 
337         preg_match_all('{
338             (?:
339                  \b(?<![\$:>])(?P<type>class|interface'.$extraTypes.') \s++ (?P<name>[a-zA-Z_\x7f-\xff:][a-zA-Z0-9_\x7f-\xff:\-]*+)
340                | \b(?<![\$:>])(?P<ns>namespace) (?P<nsname>\s++[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+(?:\s*+\\\\\s*+[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+)*+)? \s*+ [\{;]
341             )
342         }ix', $contents, $matches);
343 
344         $classes = [];
345         $namespace = '';
346 
347         for ($i = 0, $len = count($matches['type']); $i < $len; ++$i) {
348             if (!empty($matches['ns'][$i])) {
349                 $namespace = str_replace([' ', "\t", "\r", "\n"], '', $matches['nsname'][$i]) . '\\';
350             } else {
351                 $name = $matches['name'][$i];
352                 // skip anon classes extending/implementing
353                 if ($name === 'extends' || $name === 'implements') {
354                     continue;
355                 }
356                 if ($name[0] === ':') {
357                     // This is an XHP class, https://github.com/facebook/xhp
358                     $name = 'xhp'.substr(str_replace(['-', ':'], ['_', '__'], $name), 1);
359                 } elseif ($matches['type'][$i] === 'enum') {
360                     // In Hack, something like:
361                     //   enum Foo: int { HERP = '123'; }
362                     // The regex above captures the colon, which isn't part of
363                     // the class name.
364                     $name = rtrim($name, ':');
365                 }
366                 $classes[] = ltrim($namespace . $name, '\\');
367             }
368         }
369 
370         return $classes;
371     }
372 }
373