FRED™  3.0
FRED™: Framework for Rapid and Easy Development
Fred.php
Go to the documentation of this file.
1 <?php
2 
3 namespace Rsi;
4 
5 ini_set('display_errors',false);
6 error_reporting(E_ALL);
7 
8 /**
9  * Framework for Rapid and Easy Development.
10  *
11  * The Fred object is at the core of the framework. From this object all components will be initialized. This is done in a
12  * 'lazy' manner. That is: only when the component is specificly called (through the component() function or with a magic
13  * __get()).
14  *
15  * This object also does autoloading and error handling. The magic __call() method is mapped to the item() function of the
16  * entity component.
17  */
18 class Fred extends Thing{
19 
20  const EVENT_HALT = 'fred:halt';
21  const EVENT_EXTERNAL_ERROR = 'fred:externalError';
22  const EVENT_SHUTDOWN = 'fred:shutdown';
23 
24  public $debug = false; //!< True for debug modus.
25  public $errorMapping = [ //!< Map a PHP error to a log level (throws an exception for others).
26  E_STRICT => Fred\Log::WARNING,
27  E_DEPRECATED => Fred\Log::WARNING
28  ];
29  public $rootPath = null;
30  public $autoloadCacheKey = 'fred:autoloadCache'; //!< Session key for classnames cache.
31  public $autoloadMissingKey = 'fred:autoloadMissing'; //!< Session key for missing classnames cache.
32  public $defaultComponentNamespace = __CLASS__; //!< Default namespace for components. If there is no class name defined for a
33  // component, then the framework will try to load the class in this namespace with the same name (ucfirst-ed).
34 
35  public $templatePath = __DIR__ . '/../../template/'; //!< Path for the framework templates.
36  public $version = null; //!< Project version.
37  public $ignoreErrors = '/^SOAP-ERROR/'; //!< Errors to ignore on shutdown (regex).
38  public $stripObjectsMemoryLimit = null;
39  public $stripObjectsMaxDepth = 10;
40 
41  protected $_startTime = null; //!< Time at which the request started.
42  protected $_initialized = false; //!< True if the framework is initialised.
43  protected $_config = []; //!< The configuration.
44  protected $_internalError = false; //!< True if an internal error has (already) occured.
45  protected $_errorHash = null; //!< Hash for the latest caught error.
46  protected $_errorBacktrace = null; //!< Backtrace for the latest caught error.
47  protected $_timeLimit = null; //!< Execution cut-off timestamp.
48 
49  protected $_autoloadNamespaces = []; //!< Autoload namespace prefix (key) en paths (value).
50  protected $_autoloadClasses = []; //!< Autoload classes (key) and files (value).
51  protected $_autoloadFiles = []; //!< Autoload files (if not covered by the previous options).
52  protected $_autoloadCache = []; //!< Register direct location of class files.
53  protected $_autoloadCacheLimit = 250; //!< Size limit for the autoload cache.
54  protected $_autoloadMissing = []; //!< Register missing autoload classes (prevent double checking).
55  protected $_sharedCacheFile = null; //!< Shared cache file.
56  protected $_sharedCacheTime = null; //!< Creation time of the shared cache.
57  protected $_sharedCacheTtl = 600; //!< Time-to-live for shared cache.
58  protected $_sharedCacheTtlSpread = 10; //!< Random spread for TTL.
59  protected $_components = []; //!< Initialized components (key = component name, value = component).
60 
61  protected $_releaseNotesFile = __DIR__ . '/../../doc/pages/notes.php';
62  protected $_releaseNotesKey = 'fred:releaseNotesTime'; //!< Session key for release notes change time.
63 
64  protected $_maintenanceTime = null; //!< Date and time set for maintenance (site inaccessible) in 'Y-m-d H:i:s' format.
65  protected $_maintenanceMessage = []; //!< Messages to show before maintenance starts. Key = seconds before start to show
66  // message (once per session), from shortest to longer. Add a colon to specify message type, e.g. '30:error' (defaults to
67  // warning). Value = message (translated, use 'time' tag to insert maintenance time, 'delta' for time left in minutes; time
68  // 'limit' and message 'type' are also available).
69  protected $_maintenanceKey = 'fred:maintenanceMessage'; //!< Session key for last maintenance message.
70 
71  /**
72  * Initialize the framework.
73  * @param string|array $config The configuration. In case of a string, the file with this name will be included.
74  */
75  public function __construct($config){
76  try{
77  $this->_startTime = $_SERVER['REQUEST_TIME_FLOAT'] ?? microtime(true);
78  $this->_config = is_array($config) ? $config : require($config);
79  spl_autoload_register([$this,'autoload'],true,true);
80  $this->init();
81  }
82  catch(\Exception $e){
83  $this->exceptionHandler($e);
84  }
85  }
86  /**
87  * Initialize the framework.
88  */
89  protected function init(){
90  $this->configure($this->_config);
91  ini_set('display_errors',$this->debug);
92  ini_set('exception_ignore_args',!$this->debug);
93  set_error_handler([$this,'errorHandler']);
94  set_exception_handler([$this,'exceptionHandler']);
95  register_shutdown_function([$this,'shutdownFunction']);
96  if(!$this->rootPath) $this->rootPath = dirname(__DIR__,5);
97  $this->publish(['startTime' => self::READABLE,'autoloadNamespaces' => self::READABLE,'autoloadClasses' => self::READABLE]);
98  try{
99  if(session_status() == PHP_SESSION_NONE) session_start();
100  if($this->_sharedCacheFile) try{
101  include($this->_sharedCacheFile);
102  if((($delta = $this->_startTime - $this->_sharedCacheTime - $this->_sharedCacheTtl) > 0) && ($delta > rand(0,1000) / 1000 * $this->_sharedCacheTtlSpread)){
103  $this->_autoloadCache = $this->_autoloadMissing = [];
104  $this->_sharedCacheTime = null;
105  }
106  }
107  catch(\Throwable $e){
108  \Rsi\File::unlink($this->_sharedCacheFile);
109  $this->log->info($e);
110  }
111  if(array_key_exists($this->autoloadCacheKey,$_SESSION))
112  $this->_autoloadCache = array_merge($this->_autoloadCache,$_SESSION[$this->autoloadCacheKey]);
113  if(array_key_exists($this->autoloadMissingKey,$_SESSION))
114  $this->_autoloadMissing = array_merge($this->_autoloadMissing,$_SESSION[$this->autoloadMissingKey]);
115  }
116  catch(\Exception $e){
117  if($this->debug) throw $e;
118  }
119  if($this->debug){
120  $this->log->debug('FRED™ framework initialized',__FILE__,__LINE__);
121  $this->releaseNotes();
122  }
123  else $this->maintenance();
124  $this->_initialized = true;
125  }
126 
127  protected function saveSharedCache(){
128  if($this->_initialized && $this->_sharedCacheFile) try{
129  file_put_contents($temp = $this->_sharedCacheFile . uniqid('-',true) . '.tmp',"<?php\n" .
130  '$this->_sharedCacheTime = ' . ($this->_sharedCacheTime ?: $this->_startTime) . '; //modified ' . date('Y-m-d H:i:s') . "\n" .
131  '$this->_autoloadCache = ' . var_export($this->_autoloadCache,true) . ";\n" .
132  '$this->_autoloadMissing = ' . var_export(count($this->_autoloadMissing) > $this->_autoloadCacheLimit ? [] : $this->_autoloadMissing,true) . ';'
133  );
134  if(!rename($temp,$this->_sharedCacheFile)) throw new \Exception("Could not rename('$temp','{$this->_sharedCacheFile}')");
135  chmod($this->_sharedCacheFile,0666);
136  }
137  catch(\Exception $e){
138  $this->log->info($e);
139  try{
140  unlink($temp);
141  }
142  catch(\Exception $e){}
143  }
144  else{
145  $_SESSION[$this->autoloadCacheKey] = array_slice($this->_autoloadCache,0,$this->_autoloadCacheLimit,true);
146  $_SESSION[$this->autoloadMissingKey] = array_slice($this->_autoloadMissing,0,$this->_autoloadCacheLimit);
147  }
148  }
149  /**
150  * Autoloader.
151  * The autoloader tries to load a class in 3 steps:
152  * - If the class is specificly mentioned in the $_autoloadClasses, then the corresponding file will be loaded.
153  * - If the class name starts with a prefix from the $_autoloadNamespaces, then the rest of the class name (namespace
154  * separator = directory separator) and the path corresponding path will be used to create the filename.
155  * - If both these options fail, all files in the $_autoloadFiles will be loaded (once) (hoping the sought class will be in
156  * there).
157  */
158  public function autoload($class_name){
159  if($this->debug && $this->_initialized) $this->log->debug(__CLASS__ . "::autoload('$class_name')",__FILE__,__LINE__,['className' => $class_name]);
160  if(array_key_exists($class_name,$this->_autoloadCache)) return require(str_replace('~',$this->rootPath,$this->_autoloadCache[$class_name]));
161  if(array_key_exists($class_name,$this->_autoloadClasses)) return require($this->_autoloadClasses[$class_name]);
162  if(!in_array($class_name,$this->_autoloadMissing)){
163  $prefix_match = false;
164  foreach($this->_autoloadNamespaces as $prefix => $paths) if(substr($class_name,0,strlen($prefix)) == $prefix){
165  $prefix_match = true;
166  foreach($paths as $path) if(is_file($filename = $path . str_replace(['_','\\'],'/',substr($class_name,strlen(rtrim($prefix,'\\')))) . '.php')){
167  $this->_autoloadCache[$class_name] = str_replace($this->rootPath,'~',$filename);
168  $this->saveSharedCache();
169  return require($filename);
170  }
171  }
172  if(!$prefix_match && ($files = $this->_autoloadFiles)){
173  $this->_autoloadFiles = false;
174  foreach($files as $filename) require($filename);
175  }
176  else{
177  $this->_autoloadMissing[] = $class_name;
178  $this->saveSharedCache();
179  }
180  }
181  }
182  /**
183  * End the request.
184  * @param int|string $status Value to pass to the exit() function.
185  */
186  public function halt($status = null){
187  if($this->event->trigger(self::EVENT_HALT,$this,$status) !== false){
188  $this->_initialized = false;
189  exit($status);
190  }
191  }
192  /**
193  * Separate objects.
194  * @param mixed $item Item to check on (recursive for arrays).
195  * @param array $objects Array to store objects in. Item will be replaced with name of key.
196  * @param int $level Current nesting level (stops at 10).
197  */
198  public function stripObjects(&$item,&$objects = false,$level = 0){
199  try{
200  if(is_object($item)){
201  if($item instanceof \Closure){
202  $reflect = new \ReflectionFunction($item);
203  $filename = $reflect->getFileName();
204  $line_no = $reflect->getStartLine();
205  $item =
206  "<closure file='$filename' line='$line_no'>\n" .
207  implode(array_slice(file($filename),$line_no - 1,$reflect->getEndLine() - $line_no + 1)) .
208  '</closure>';
209  }
210  elseif($objects !== false){
211  if(!$this->stripObjectsMemoryLimit) $this->stripObjectsMemoryLimit = \Rsi::memoryLimit() / 5;
212  if(memory_get_usage() > $this->stripObjectsMemoryLimit) $item = 'out of memory';
213  $objects[$key = '@@object_' . md5(print_r($item,true)) . '@@'] = $item;
214  $item = $key;
215  }
216  else $item = '** object **';
217  }
218  elseif(is_array($item)){
219  if($level >= $this->stripObjectsMaxDepth) $item = '** max depth **';
220  else foreach($item as &$sub) $this->stripObjects($sub,$objects,$level + 1);
221  unset($sub);
222  }
223  }
224  catch(\Exception $e){
225  $item = '@@' . $e->getMessage() . '@@';
226  }
227  }
228  /**
229  * Separate objects from a trace.
230  * @param array $trace
231  * @param array $objects Array to store objects in. Item will be replaced with name of key.
232  */
233  public function stripTraceObjects(&$trace,&$objects){
234  foreach($trace as &$step){
235  if(array_key_exists('object',$step)) $this->stripObjects($step['object'],$objects);
236  if(array_key_exists('args',$step) && $step['args']) $this->stripObjects($step['args'],$objects);
237  else unset($step['args']);
238  }
239  unset($step);
240  }
241  /**
242  * Handle an (deliberately caused) external error.
243  */
244  public function externalError($message,$context = null){
245  if($this->debug){
246  print($message . "\n\n");
247  if(!\Rsi::commandLine()){
248  $trace = debug_backtrace();
249  $objects = [];
250  $this->stripTraceObjects($trace,$objects);
251  print_r($context);
252  print_r($trace);
253  print_r($objects);
254  }
255  $this->halt('External error');
256  }
257  $this->log->notice('External error: ' . $message,$context);
258  if($this->event->trigger(self::EVENT_EXTERNAL_ERROR,$this,$message) !== false){
259  http_response_code(400); //Bad Request
260  $_SESSION = [];
261  $this->halt();
262  }
263  }
264 
265  protected function printError($message,$filename,$line_no,$trace){
266  print($message . ($filename ? " ($filename" . ($line_no ? '@' . $line_no : null) . ")" : null) . "\n\n");
267  foreach($trace as $step) print(
268  ($step['file'] ?? null) . (($line_no = $step['line'] ?? 0) ? '@' . $line_no : null) . ': ' .
269  (($class_name = $step['object'] ?? $step['class'] ?? null) ? $class_name . ($step['type'] ?? '::') : null) . ($step['function'] ?? null) . "()\n"
270  );
271  }
272  /**
273  * Handle an internal error.
274  * @param string $message Error message.
275  * @param string $filename File in which the error occured.
276  * @param int $line_no Line at which the error occured.
277  * @param array $trace Backtrace of the moment the error occured.
278  */
279  public function internalError($message,$filename = null,$line_no = null,$trace = null){
280  try{
281  if(ob_get_length()) ob_clean();
282  $objects = [];
283  if($this->debug && (strpos($message,'Undefined variable: ') === 0)){
284  $this->_internalError = true;
285  $trace = false;
286  }
287  else{
288  if(!$trace) $trace = debug_backtrace();
289  if($this->debug) $this->log->emergency($message,$filename,$line_no);
290  $this->stripTraceObjects($trace,$objects);
291  }
292  if($this->_internalError){
293  if($this->debug){
294  if(\Rsi::commandLine()) $this->printError($message,$filename,$line_no,$trace);
295  else{
296  print("<code>$message ($filename:$line_no)<code><br><br>");
297  $dump = $this->dump();
298  print($dump->head() . $dump->source($filename,$line_no));
299  if($trace) print($dump->var('trace',$trace));
300  if($objects) print($dump->var('objects',$objects));
301  }
302  }
303  exit('Internal error');
304  }
305  $this->_internalError = true;
306 
307  if(!$this->debug) try{
308  $this->log->emergency($message,$filename,$line_no,array_filter([
309  'trace' => $trace,
310  'objects' => $objects,
311  'headers' => function_exists('getallheaders') ? getallheaders() : null,
312  'args' => $_SERVER['argv'] ?? null,
313  'GET' => $_GET,
314  'POST' => $_POST,
315  'COOKIE' => $_COOKIE,
316  'SESSION' => isset($_SESSION) ? $_SESSION : null
317  ]));
318  usleep(rand(0,10000000));
319  http_response_code(500);
320  if(\Rsi::commandLine()) print("Error in '$filename' on line $line_no at " . date('Y-m-d H:i:s'));
321  elseif(is_file($template = $this->templatePath . 'error.php')) require($template);
322  }
323  catch(\Exception $e){
324  print('An unexpected error has occurred');
325  }
326  elseif(!\Rsi::commandLine() && is_file($template = $this->templatePath . 'debug.php')) require($template);
327  else $this->printError($message,$filename,$line_no,$trace);
328  $this->halt();
329  }
330  catch(\Exception $e){ //the default exception handler is not called again on an unhandled exception
331  $this->internalError($e->getMessage(),$e->getFile(),$e->getLine(),$e->getTrace());
332  }
333  }
334 
335  protected function errorHash($message,$filename,$line_no){
336  return md5("errorHash($message,$filename,$line_no)");
337  }
338  /**
339  * Error handler.
340  * Throws an exception to get a unified error handling. Errors that are suppressed are only logged.
341  */
342  public function errorHandler($error_no,$message,$filename,$line_no){
343  if((error_reporting() & $error_no) && !array_key_exists($error_no,$this->errorMapping)){
344  $this->_errorHash = $this->errorHash($message,$filename,$line_no);
345  $this->_errorBacktrace = debug_backtrace();
346  throw new \ErrorException($message,$error_no,0,$filename,$line_no);
347  }
348  elseif($this->_initialized) $this->log->add($this->errorMapping[$error_no] ?? Fred\Log::INFO,$message,$filename,$line_no,['trace' => array_map(function($step){
349  unset($step['args']);
350  return $step;
351  },debug_backtrace(false))]);
352  }
353  /**
354  * Exception handler.
355  * @param Exception $exception
356  */
357  public function exceptionHandler($exception){
358  $message = $exception->getMessage();
359  $filename = $exception->getFile();
360  $line_no = $exception->getLine();
361  if(!($exception instanceof \ErrorException) || ($this->errorHash($message,$filename,$line_no) != $this->_errorHash)) $this->_errorBacktrace = null;
362  $this->internalError($message,$filename,$line_no,$this->_errorBacktrace ?: $exception->getTrace());
363  }
364  /**
365  * Shutdown function.
366  * If a (non catched) error is the reason for the shutdown (e.g. a timeout), then the shutdown will be processed as an
367  * internal error.
368  * @see internalError()
369  */
370  public function shutdownFunction(){
371  if(
372  $this->_initialized && !$this->_internalError &&
373  ($error = error_get_last()) &&
374  !array_key_exists($error['type'],$this->errorMapping) &&
375  !preg_match($this->ignoreErrors,$error['message']) &&
376  ($this->event->trigger(self::EVENT_SHUTDOWN,$this,$error) !== false)
377  ) $this->internalError($error['message'],$error['file'],$error['line']);
378  }
379  /**
380  * Version without hash.
381  * @param string $hash Hash from version string.
382  * @return string Version number.
383  */
384  public function version(&$hash = null){
385  list($version,$hash) = explode('-',$this->version . '-',2);
386  return $version;
387  }
388  /**
389  * Add new release notes to messages and log.
390  */
391  protected function releaseNotes(){
392  if(
393  $this->_releaseNotesFile &&
394  ($time = File::mtime($this->_releaseNotesFile)) &&
395  ($time != Record::get($_SESSION,$this->_releaseNotesKey)) &&
396  preg_match_all('/\\n- ([\\d\\.]+): (.*)/',file_get_contents($this->_releaseNotesFile),$matches,PREG_SET_ORDER)
397  ){
398  $messages = [];
399  foreach($matches as $match){
400  $version = $match[1] . '-' . str_replace(' ','-',strtolower(\Rsi\Str::codeName(crc32($match[2]))));
401  if($this->version == $version) $messages = [];
402  else $messages[] = "FRED™ version $version: " . str_replace('\\\\','\\',$match[2]);
403  }
404  if($messages){
405  foreach($messages as $message){
406  $this->message->warning($message);
407  $this->log->warning($message,__FILE__,__LINE__);
408  }
409  $this->log->notice("Upgraded to FRED™ version $version.",__FILE__,__LINE__);
410  }
411  else $_SESSION[$this->_releaseNotesKey] = $time;
412  }
413  }
414  /**
415  * Check for maintenance.
416  */
417  protected function maintenance(){
418  if($this->_maintenanceTime){
419  if(($delta = (strtotime($this->_maintenanceTime)) - $this->_startTime) <= 0){
420  if(is_file($template = $this->templatePath . 'maintenance.php')) require($template);
421  http_response_code(503); //Service Unavailable
422  $_SESSION = [];
423  $this->halt();
424  }
425  else foreach($this->_maintenanceMessage as $limit => $message){
426  list($limit,$type) = explode(':',$limit . ':warning');
427  if($delta <= $limit){
428  if($limit != \Rsi\Record::get($_SESSION,$this->_maintenanceKey)){
429  $this->message->add($type,$message,['time' => $this->_maintenanceTime,'delta' => round($delta / 60)] + compact('limit','type'));
430  $_SESSION[$this->_maintenanceKey] = $limit;
431  }
432  break;
433  }
434  }
435  }
436  }
437  /**
438  * Replace config keys with variables.
439  * @param mixed $config For arrays, values with keys starting with a '@' the value is replaced by the variable with that
440  * name (and the '@' is removed from the key).
441  * @return mixed
442  */
443  public function replaceVars($config){
444  if(!is_array($config)) return $config;
445  $result = [];
446  foreach($config as $key => $value) if(substr($key,0,1) == '@')
447  $result[substr($key,1)] = (substr($value,0,1) == '{') && (substr($value,-1) == '}')
448  ? json_decode($this->vars->value(substr($value,1,-1)),true)
449  : $this->vars->value($value);
450  else $result[$key] = $this->replaceVars($value);
451  return $result;
452  }
453  /**
454  * Get a dumper.
455  * @return \\Rsi\\Dump
456  */
457  public function dump(){
458  $class_name = \Rsi\Record::get($config = $this->config('dump',[]),'className','Rsi\\Dump');
459  return new $class_name($config + ['rootPath' => $this->rootPath]);
460  }
461  /**
462  * Get a value from the configuration.
463  * @param string|array $key Key to get the value from the configuration (array = nested).
464  * @param mixed $default Default value if the key does not exist.
465  * @return mixed Found value, or default value if the key does not exist.
466  */
467  public function config($key,$default = null){
468  return $this->replaceVars(Record::get($this->_config,$key,$default));
469  }
470 
471  protected function defaultComponentClassName($name){
472  return $this->defaultComponentNamespace . '\\' . ucfirst($name);
473  }
474  /**
475  * Get a component.
476  * @param string $name Name of the component.
477  * @return Fred\\Component
478  */
479  public function component($name){
480  if(!$this->has($name)){
481  $config = $this->config($name);
482  if($config && !is_array($config)){
483  if(is_callable($config)) $config = call_user_func($config,$name);
484  if(is_string($config)) $config = require($config);
485  }
486  if(!class_exists($class_name = Record::get($config,'className') ?: $this->defaultComponentClassName($name)))
487  throw new \Exception("Unknown component '$name' ($class_name)");
488  $this->_components[$name] = new $class_name($this,array_merge(['name' => $name],$config ?: []));
489  }
490  return $this->_components[$name];
491  }
492  /**
493  * Get a component if there is a configuration entry for it.
494  * @param string $name Name of the component.
495  * @return Fred\\Component False if there is no configuration.
496  */
497  public function may($name){
498  return array_key_exists($name,$this->_components) || array_key_exists($name,$this->_config) || class_exists($this->defaultComponentClassName($name))
499  ? $this->component($name)
500  : false;
501  }
502  /**
503  * Get a component if it already exists.
504  * @param string $name Name of the component.
505  * @return Fred\\Component False if it did not exist.
506  */
507  public function has($name){
508  return $this->_components[$name] ?? false;
509  }
510  /**
511  * Public configuration.
512  * @return array Public configuration for all components (key = component name, value = public component configuration).
513  */
514  public function clientConfig(){
515  $config = [];
516  if($this->debug) $config['debug'] = true;
517  foreach($this->_components as $name => $component) $config[$name] = $component->clientConfig();
518  return array_filter($config);
519  }
520 
521  protected function setAutoloadNamespaces($namespaces){
522  $this->_autoloadNamespaces = array_merge($this->_autoloadNamespaces,$namespaces);
523  }
524 
525  protected function setAutoloadClasses($classes){
526  $this->_autoloadClasses = array_merge($this->_autoloadClasses,$classes);
527  }
528 
529  protected function setAutoloadFiles($classes){
530  $this->_autoloadFiles = array_merge($this->_autoloadFiles,$classes);
531  }
532 
533  protected function setTimeLimit($value){
534  set_time_limit($value);
535  $this->_timeLimit = microtime(true) + $value;
536  }
537 
538  protected function getTimeLimit(){
539  if($this->_timeLimit === null) $this->_timeLimit = $this->_startTime + ini_get('max_execution_time');
540  return max(0,$this->_timeLimit - microtime(true));
541  }
542 
543  protected function _get($key){
544  return $this->component($key);
545  }
546 
547  public function __call($func_name,$params){
548  return call_user_func_array([$this->entity,'item'],array_merge([$func_name],$params));
549  }
550 
551 }
Rsi