FRED™  3.0
FRED™: Framework for Rapid and Easy Development
Router.php
Go to the documentation of this file.
1 <?php
2 
3 namespace Rsi\Fred;
4 
5 class Router extends Component{
6 
7  const CACHE_HASH = 'hash';
8  const CACHE_TIME = 'time';
9  const CACHE_ROUTES = 'routes';
10  const CACHE_HASHED = 'hashed';
11 
12  const ENUM_TRANS = '@';
13  const ENUM_DYNAMIC = '#';
14 
15  public $prefix = '/'; //!< Prefix to add before each route.
16  public $routes = []; //!< Available routes (key = route, value = controller data). Parameters can be present in a route (e.g.
17  // [id]). These will be added to the $_GET. Parameters can be appended with a reg-ex pattern (e.g. [id:\\d+] = numeric
18  // only). Component properties can be linked by using a parameter format like [@component.property]. Parameters can be
19  // enumerated by adding a semicolon seperated list like [id*internal=external;i2=x2;default] (default is optional). In this
20  // case a reg-ex pattern should match the external values. The controller data can be either a direct controller name
21  // (string) or an array. In the case of a string static parameters can be added to the controller name as query parameters
22  // (e.g. ?key=value), and a specific action as a fragment (e.g. #action). The fragment must come before the parameters (e.g.
23  // #action?key=value). When the controller data is in the shape of an array all these items have their own key (all optional
24  // unless noted otherwise):
25  // - name: Controller name (required).
26  // - action: Specific action.
27  // - extra: Extra parameters that will be added to the $_POST.
28  // - match: Callback function for extra fine grained matching. Parameters for the function are:
29  // - router (object): Reference to this component.
30  // - route (string): Route to check against.
31  // - path (string): Path from the request.
32  // - values (array): Paramter values from the match against the route.
33  // - reverse: Callback function for link generating. Parameters for the function are:
34  // - router (object): Reference to this component.
35  // - link (string, by reference): Generated link (without type and query).
36  // - type (string, by reference): View type.
37  // - query (array, by reference): Parameters to add to the link.
38  // - encrypt: Data for encrypting parameters (all otional):
39  // - key (string): Key to use to add the encrypted data to the parameters (defaults to see @encryptKey).
40  // - salt (string): Custom key for encryption.
41  // - keys (array): Keys of the parameters to include in the encrypted data (defaults to all).
42  // When the provided data can't be decrypted, all parameters (in the keys array) are set to null.
43  // - hash: Data for calculating a checksum hash (all optional):
44  // - key (string): Key to use to add the hash to the parameters (defaults to see @hashKey).
45  // - algo (string): Hashing algorithm to use (defaults to see @hashAlgo).
46  // - salt (string): Salt to use for the hashing (defaults to the key of the encrypt component).
47  // - keys (array): Keys of the parameters to include in the hash (defaults to all).
48  // When the provided hash doesn't match with the calculated hash, all parameters (in the keys array) are set to null. A
49  // hash is only possible when there is no encrypted data.
50  public $defaultParams = []; //!< Default params for routes.
51  public $regexShortcuts = [
52  '*' => '\\d+', //integer, 0 and up
53  '+' => '[1-9]\\d*' //integer, 1 and up
54  ];
55  public $enumTransTtl = 3600; //!< Time-to-live for translated enum options.
56  public $hashKey = 'hash'; //!< Default hash key.
57  public $hashAlgo = 'sha256'; //!< Default hash algo.
58  public $encryptKey = 'data'; //!< Default encrypted data key.
59 
60  protected $_sharedCacheFile = null; //!< Shared cache file for compiled routes.
61  protected $_sharedCacheTtlSpread = 10; //!< Random spread for TTL.
62 
63  protected $_cache = null;
64  protected $_save = false;
65  protected $_viewType = null;
66  protected $_controllerName = null;
67  protected $_hashed = false;
68 
69  protected function done(){
70  if($this->_save){
71  if($this->_sharedCacheFile) try{
72  \Rsi\File::export($temp = $this->_sharedCacheFile . \Rsi\Str::random() . '.tmp',$this->_cache);
73  if(!rename($temp,$this->_sharedCacheFile)) throw new \Exception("Could not rename('$temp','{$this->_sharedCacheFile}')");
74  chmod($this->_sharedCacheFile,0666);
75  }
76  catch(\Exception $e){
77  $this->component('log')->info($e);
78  \Rsi\File::unlink($temp);
79  }
80  else $this->session->cache = $this->_cache;
81  }
82  parent::done();
83  }
84 
85  protected function stripClosures($value){
86  array_walk_recursive($value,function(&$item){
87  if(is_object($item) && ($item instanceof \Closure)) $item = true;
88  });
89  return $value;
90  }
91  /**
92  * Retrieve data from the route cache.
93  * @param string $route
94  * @param string $key
95  * @return mixed Data for this route + key.
96  */
97  protected function cache($route,$key){
98  if($this->_cache === null){
99  $this->_cache = [];
100  $empty = [self::CACHE_ROUTES => [],self::CACHE_HASHED => []];
101  if($this->_sharedCacheFile) try{
102  $this->_cache = include($this->_sharedCacheFile);
103  if(
104  (($this->_cache[self::CACHE_HASH] ?? null) != ($hash = crc32('v4' . print_r($this->stripClosures($this->routes),true)))) ||
105  (($time = $this->_cache[self::CACHE_TIME] ?? null) && ($time < time() + rand(0,1000) / 1000 * $this->_sharedCacheTtlSpread))
106  ){
107  $this->_cache = [self::CACHE_HASH => $hash] + $empty;
108  $this->session->lastPath = $this->session->lastRoute = null;
109  }
110  }
111  catch(\Throwable $e){
112  \Rsi\File::unlink($this->_sharedCacheFile);
113  $this->component('log')->info($e);
114  $this->_cache = $empty;
115  $this->_save = true;
116  }
117  else $this->_cache = $this->session->cache ?: $empty;
118  }
119  if(!array_key_exists($route,$this->_cache[self::CACHE_ROUTES])) $this->_cache[self::CACHE_ROUTES][$route] = [];
120  return $this->_cache[self::CACHE_ROUTES][$route][$key] ?? null;
121  }
122  /**
123  * Save data to the route cache.
124  * @param string $route Route (set tot false for hash).
125  * @param string $key
126  * @param mixed $value Data for this route + key.
127  */
128  protected function save($route,$key,$value){
129  if($route === false) $this->_cache[self::CACHE_HASHED][$key] = $value;
130  else $this->_cache[self::CACHE_ROUTES][$route][$key] = $this->stripClosures($value);
131  $this->_save = true;
132  }
133 
134  protected function enumLang($params){
135  return $this->component('trans')->langs();
136  }
137  /**
138  * Parse a route and generate a mask (regex).
139  * @param string $route
140  * @param string $mask Resulting regex.
141  * @param array $slots Slots for every paramter in the route.
142  */
143  protected function mask($route,&$mask,&$slots){
144  if($cache = $this->cache($route,'mask')) extract($cache);
145  else{
146  preg_match_all('/\\[(.+?)(|\\*.+?)(|:[^\\[\\]]*(\\[.+?\\])*[^\\[\\]]*)\\]/',$mask = '/^' . str_replace('/','\\/',$route) . '$/i',$matches,PREG_SET_ORDER); //parameters in the route
147  $slots = [];
148  foreach($matches as list($full,$key,$enum,$regex)){
149  $slot = 'slot' . count($slots);
150  if($enum){
151  $ttl = false;
152  switch(substr($enum,1,1)){
153  case self::ENUM_TRANS:
154  $trans = $this->component('trans');
155  $str_id = substr($enum,2);
156  $enum = [];
157  foreach($trans->langs() as $lang_id => $descr) $enum[$lang_id] = $trans->str("[@$str_id.$lang_id]");
158  $ttl = $this->enumTransTtl;
159  break;
160  case self::ENUM_DYNAMIC:
161  if(method_exists($this,$method = 'enum' . ucfirst(substr($enum,2)))) $enum = $method;
162  break;
163  default:
164  $enum = \Rsi\Record::explode(substr($enum,1),';','=');
165  }
166  if($ttl) $this->_cache[self::CACHE_TIME] = ($time = $this->_cache[self::CACHE_TIME] ?? null) ? min($time,time() + $ttl) : time() + $ttl;
167  }
168  if($regex && array_key_exists($regex = substr($regex,1),$this->regexShortcuts)) $regex = $this->regexShortcuts[$regex];
169  if($component = preg_match('/^@(\\w+)\\.(\\w+)$/',$key,$match)) $key = 'component' . count($slots);
170  $slots[$key] =
171  compact('full','regex','slot','enum') +
172  ($component ? ['component' => $match[1],'property' => $match[2]] : []);
173  $mask = str_replace($full,"(?<$slot>.*?)",$mask);
174  }
175  $mask = strtr($mask,['{' => '(?:','}' => ')?']);
176  foreach($slots as $slot) if($slot['regex'] && extract($slot))
177  $mask = str_replace("<$slot>.*?","<$slot>$regex",$mask);
178  $this->save($route,'mask',compact('mask','slots'));
179  }
180  }
181  /**
182  * Parsed data for route.
183  * @param string $route
184  * @return array Data.
185  */
186  protected function data($route){
187  if($data = $this->cache($route,'data')) return $data;
188  if(!is_array($name = $data = $this->routes[$route])){
189  parse_str(\Rsi\Str::pop($name,'?'),$extra);
190  $action = \Rsi\Str::pop($name,'#');
191  $data = compact('name','action','extra');
192  }
193  $this->save($route,'data',$data);
194  return $data;
195  }
196  /**
197  * Parse an (dynamic) enum.
198  * @param mixed $enum Raw enum.
199  * @param array $params Current set of parameters.
200  * @param array $data Route data.
201  * @return array
202  */
203  protected function enum($enum,$params,$data){
204  return is_string($enum) ? call_user_func([$this,$enum],$params,$data) : $enum;
205  }
206  /**
207  * Calculate the hash for a set of parameters.
208  * @param array $hash Hash configuration.
209  * @param array $params Route parameters (values).
210  * @return string Hash.
211  */
212  protected function hash($hash,$params){
213  unset($params[$hash['key'] ?? $this->hashKey]);
214  ksort($params);
215  return hash(
216  $hash['algo'] ?? $this->hashAlgo,
217  ($hash['salt'] ?? $this->component('encrypt')->key) .
218  print_r(array_key_exists('keys',$hash) ? \Rsi\Record::select($params,$hash['keys']) : $params,true)
219  );
220  }
221  /**
222  * Encrypt a set of parameters.
223  * @param array $encrypt Encryption parameters.
224  * @param array $params Route parameters (values).
225  * @param array $keys Keys encrypted (return).
226  * @return string Encrypted data (binary).
227  */
228  protected function encrypt($encrypt,$params,&$keys){
229  return $this->component('encrypt')->str(
230  serialize(\Rsi\Record::select($params,$keys = $encrypt['keys'] ?? array_keys($params))),
231  $encrypt['salt'] ?? null
232  );
233  }
234  /**
235  * Check if the current controller (+ action) has a hashed version.
236  * @param string $key Cache key.
237  * @return array Keys for the parametes in the hash (or false if not found).
238  */
239  protected function hashed(&$key = null){
240  if($this->_cache === null) $this->cache(null,null);
241  return array_key_exists($key = strtolower($this->_controllerName . '#' . \Rsi\Str::check($_POST['action'] ?? null)),$this->_cache[self::CACHE_HASHED])
242  ? $this->_cache[self::CACHE_HASHED][$key]
243  : false;
244  }
245  /**
246  * Check if a path matches a route and extract the parameters.
247  * @param string $path Path to check.
248  * @param string $route Route to check against.
249  * @return string Controller name on match (null = no match, false = unsatisfied enum).
250  */
251  protected function match($path,$route){
252  if(array_key_exists($route,$this->routes)){
253  $data = $this->data($route);
254  $this->mask($route,$mask,$slots);
255  if(preg_match($mask,$path,$values) && (!array_key_exists('match',$data) || call_user_func($this->routes[$route]['match'],$this,$route,$path,$values))){ //path fits this route
256  $name = $data['name'];
257  $params = [];
258  foreach($slots as $key => $slot) if(array_key_exists($slot['slot'],$values)){
259  $component = $property = null;
260  if(extract($slot) && (($value = $values[$slot]) || !$regex || preg_match("/^$regex$/",$value))){
261  $value = urldecode($values[$slot]);
262  if($component) $component = $this->component($component);
263  if($enum){
264  $enum = $this->enum($enum,$params,$data);
265  if((!$component || (($enum[$component->get($property)] ?? null) != $value)) && (($value = \Rsi\Record::isearch($enum,$value)) === false)) return false;
266  }
267  if($component) $component->set($property,$value);
268  else \Rsi\Record::set($params,explode('.',$key),$value);
269  if(preg_match('/^\\w+$/',$value)) $name = str_replace("[$key]",$value,$name);
270  }
271  }
272  $this->_controllerName = $name;
273  $_GET = $params + $_GET;
274  if($action = $data['action'] ?? null) $_POST['action'] = $action;
275  if(array_key_exists('encrypt',$data) && ($value = \Rsi\Str::check($_GET[($encrypt = $data['encrypt'])['key'] ?? $this->encryptKey] ?? null))) try{
276  if($this->hashed($cache,'encrypt') === false) $this->save(false,$cache,$encrypt['keys'] ?? null);
277  $_GET = array_merge($_GET,unserialize($this->component('encrypt')->decrypt(hex2bin($value),$encrypt['salt'] ?? null)));
278  $this->_hashed = true;
279  }
280  catch(\Exception $e){
281  $this->component('log')->info($e);
282  }
283  elseif(array_key_exists('hash',$data)){
284  $hash = $data['hash'];
285  $keys = $hash['keys'] ?? null;
286  if($this->hashed($cache) === false) $this->save(false,$cache,$keys);
287  if(hash_equals($this->hash($hash,$_GET),strval(\Rsi\Str::check($_GET[$hash['key'] ?? $this->hashKey] ?? null)))) $this->_hashed = true;
288  else foreach(($keys ?: array_keys($_GET)) as $key) $_GET[$key] = $_POST[$key] = null;
289  }
290  $_POST = array_merge($_POST,$data['extra'] ?? []);
291  return $this->_controllerName;
292  }
293  }
294  return null;
295  }
296  /**
297  * Determine controller name and type.
298  * If the controller name matches a route, the name will be translated to this route, and parameters present will be added to
299  * the $_GET.
300  * @param string $path If the path is not given, then the path after the script itself ('foo/bar' in '/index.php/foo/bar')
301  * will be used.
302  */
303  public function execute($path = null){
304  $this->_viewType = $this->_controllerName = $this->_hashed = null;
305  if($path === null) $path = $this->pathInfo;
306  if($path && ($path = trim(preg_replace('/^' . preg_quote($this->prefix,'/') . '/i','',$path),'/'))){
307  if($this->_viewType = strtolower(pathinfo($path,PATHINFO_EXTENSION)))
308  $path = substr($path,0,-(1 + strlen($this->_viewType)));
309  if($path != $this->session->lastPath){
310  $this->session->lastPath = $path;
311  $this->session->lastRoute = null;
312  foreach($this->routes as $route => $data) if($this->match($path,$route)){
313  $this->session->lastRoute = $route;
314  break;
315  }
316  }
317  elseif($route = $this->session->lastRoute) $this->match($path,$route);
318  if(!$this->_controllerName) $this->_controllerName = ucwords(strtolower($path),'/');
319  }
320  if(!$this->_hashed && (($keys = $this->hashed()) !== false)){
321  if($keys) foreach($keys as $key) $_GET[$key] = $_POST[$key] = null;
322  else $_GET = $_POST = [];
323  }
324  $_GET += $this->defaultParams;
325  }
326  /**
327  * Format a (default) route.
328  * @param string $route Base route.
329  * @return string Formatted route.
330  */
331  protected function format($route){
332  return strtolower($route);
333  }
334  /**
335  * Determine a route from a controller name and type.
336  * @param string $controller_name (empty = current)
337  * @param string $type
338  * @param array $params Parameters to use with the route.
339  * @param bool $strict Whether too check the format of the parameters.
340  * @return string Found route. Parameters that were not used in the route are added in the query.
341  */
342  public function reverse($controller_name = null,$type = null,$params = null,$strict = true){
343  if(!$controller_name) $controller_name = $this->controllerName;
344  $link = $this->format($controller_name);
345  $params = ($params ?: []) + $this->defaultParams;
346  $match = false;
347  foreach($this->routes as $route => $data){
348  $data = $this->data($route);
349  if($data['name'] == $controller_name){
350  $query = $params;
351  if(array_key_exists('extra',$data)) foreach($data['extra'] as $key => $value){
352  if(($query[$key] ?? null) != $value) continue 2;
353  unset($query[$key]);
354  }
355  if(array_key_exists('reverse',$data)) $data['reverse'] = $this->routes[$route]['reverse'];
356  if(array_key_exists('encrypt',$data)){
357  $value = $this->encrypt($data['encrypt'],$query,$keys);
358  foreach($keys as $key) unset($query[$key]);
359  $query[$data['encrypt']['key'] ?? $this->encryptKey] = bin2hex($value);
360  }
361  elseif(array_key_exists('hash',$data)) $query[$data['hash']['key'] ?? $this->hashKey] = $this->hash($data['hash'],$params);
362  $this->mask($route,$mask,$slots);
363  foreach($slots as $key => $slot) if($slot['component'] ?? null)
364  $query[$key] = $this->component($slot['component'])->get($slot['property']);
365  foreach($query as $key => $value) if(array_key_exists($key,$slots) && is_scalar($value) && extract($slots[$key])){
366  if($enum){
367  $enum = $this->enum($enum,$params,$data);
368  $value = $enum[$value] ?? $enum[null] ?? null;
369  }
370  if(!$strict || !$regex || (($value !== null) && preg_match("/^$regex$/",$value))){
371  $route = str_replace($full,rawurlencode($value ?? ''),$route);
372  unset($query[$key]);
373  }
374  }
375  do($route = preg_replace('/{[^{}]*?\\[[^\\[]*?\\][^{}]*?}/','',$route,-1,$count)); //remove optional parts with missing params
376  while($count);
377  if($match = strpos($route,'[') === false){
378  $link = str_replace(['{','}'],'',$route);
379  if(array_key_exists('reverse',$data)) call_user_func_array($data['reverse'],[$this,&$link,&$type,&$query]);
380  break;
381  }
382  }
383  }
384  if($type) $link .= '.' . $type;
385  if($query = http_build_query($match ? $query : $params)) $link .= (strpos($link,'?') === false ? '?' : '&') . $query;
386  return $this->prefix . $link;
387  }
388 
389  protected function getControllerName(){
390  if($this->_controllerName === null) $this->execute();
391  return $this->_controllerName;
392  }
393 
394  protected function getPathInfo(){
395  return parse_url($_SERVER['REQUEST_URI'] ?? null,PHP_URL_PATH);
396  }
397 
398  protected function getViewType(){
399  if($this->_controllerName === null) $this->execute();
400  return $this->_viewType;
401  }
402 
403 }
Rsi\Fred\Router\mask
mask($route, &$mask, &$slots)
Parse a route and generate a mask (regex).
Definition: Router.php:143
Rsi\Fred\Router\CACHE_HASH
const CACHE_HASH
Definition: Router.php:7
Rsi\Fred\Router\$prefix
$prefix
Prefix to add before each route.
Definition: Router.php:15
Rsi\Fred\Router\done
done()
Definition: Router.php:69
Rsi\Fred\Router\getPathInfo
getPathInfo()
Definition: Router.php:394
Rsi\Fred\Router\stripClosures
stripClosures($value)
Definition: Router.php:85
Rsi\Fred\Router\CACHE_ROUTES
const CACHE_ROUTES
Definition: Router.php:9
Rsi
Rsi\Fred\Router
Definition: Router.php:5
Rsi\Fred\Router\format
format($route)
Format a (default) route.
Definition: Router.php:331
Rsi\Fred\Router\enumLang
enumLang($params)
Definition: Router.php:134
Rsi\Fred\Router\hash
hash($hash, $params)
Calculate the hash for a set of parameters.
Definition: Router.php:212
Rsi\Fred\Router\cache
cache($route, $key)
Retrieve data from the route cache.
Definition: Router.php:97
Rsi\Fred\Router\$_viewType
$_viewType
Definition: Router.php:65
Rsi\Fred\Router\save
save($route, $key, $value)
Save data to the route cache.
Definition: Router.php:128
Rsi\Fred\Router\$_controllerName
$_controllerName
Definition: Router.php:66
Rsi\Fred\Router\CACHE_HASHED
const CACHE_HASHED
Definition: Router.php:10
Rsi\Fred\Router\$routes
$routes
Available routes (key = route, value = controller data). Parameters can be present in a route (e....
Definition: Router.php:16
Rsi\Fred\Router\encrypt
encrypt($encrypt, $params, &$keys)
Encrypt a set of parameters.
Definition: Router.php:228
Rsi\Fred\Router\getViewType
getViewType()
Definition: Router.php:398
Rsi\Fred\Router\$defaultParams
$defaultParams
Default params for routes.
Definition: Router.php:50
Rsi\Fred\Component
Basic component class.
Definition: Component.php:8
Rsi\Fred\Router\$_save
$_save
Definition: Router.php:64
Rsi\Fred\Component\component
component($name)
Get a component (local or default).
Definition: Component.php:81
Rsi\Fred\Router\reverse
reverse($controller_name=null, $type=null, $params=null, $strict=true)
Determine a route from a controller name and type.
Definition: Router.php:342
Rsi\Fred\Router\$encryptKey
$encryptKey
Default encrypted data key.
Definition: Router.php:58
Rsi\Fred\Router\ENUM_DYNAMIC
const ENUM_DYNAMIC
Definition: Router.php:13
Rsi\Fred\Router\hashed
hashed(&$key=null)
Check if the current controller (+ action) has a hashed version.
Definition: Router.php:239
Rsi\Fred\Router\$hashAlgo
$hashAlgo
Default hash algo.
Definition: Router.php:57
Rsi\Fred\Router\execute
execute($path=null)
Determine controller name and type.
Definition: Router.php:303
Rsi\Fred\Router\$_sharedCacheFile
$_sharedCacheFile
Shared cache file for compiled routes.
Definition: Router.php:60
Rsi\Fred\Router\$regexShortcuts
$regexShortcuts
Definition: Router.php:51
Rsi\Fred\Router\$_sharedCacheTtlSpread
$_sharedCacheTtlSpread
Random spread for TTL.
Definition: Router.php:61
Rsi\Fred\Router\$enumTransTtl
$enumTransTtl
Time-to-live for translated enum options.
Definition: Router.php:55
Rsi\Fred\Router\data
data($route)
Parsed data for route.
Definition: Router.php:186
Rsi\Fred\Router\$hashKey
$hashKey
Default hash key.
Definition: Router.php:56
Rsi\Fred\Router\match
match($path, $route)
Check if a path matches a route and extract the parameters.
Definition: Router.php:251
Rsi\Fred\Router\$_cache
$_cache
Definition: Router.php:63
Rsi\Fred\Router\CACHE_TIME
const CACHE_TIME
Definition: Router.php:8
Rsi\Fred\Router\$_hashed
$_hashed
Definition: Router.php:67
Rsi\Fred\Exception
Definition: Exception.php:5
Rsi\Fred\Router\ENUM_TRANS
const ENUM_TRANS
Definition: Router.php:12
Rsi\Fred
Definition: Alive.php:3
Rsi\Fred\Router\getControllerName
getControllerName()
Definition: Router.php:389