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_ROUTES = 'routes';
9  const CACHE_HASHED = 'hashed';
10 
11  public $prefix = '/'; //!< Prefix to add before each route.
12  public $routes = []; //!< Available routes (key = route, value = controller data). Parameters can be present in a route (e.g.
13  // [id]). These will be added to the $_GET. Parameters can be appended with a reg-ex pattern (e.g. [id:\\d+] = numeric
14  // only). Component properties can be linked by using a parameter format like [@component.property]. Parameters can be
15  // enumerated by adding a semicolon seperated list like [id*internal=external;i2=x2;default] (default is optional). In this
16  // case a reg-ex pattern should match the external values. The controller data can be either a direct controller name
17  // (string) or an array. In the case of a string static parameters can be added to the controller name as query parameters
18  // (e.g. ?key=value), and a specific action as a fragment (e.g. #action). The fragment must come before the parameters
19  // (e.g. #action?key=value). When the controller data is in the shape of an array all these items have their own key (all
20  // optional unless noted otherwise):
21  // - name: Controller name (required).
22  // - action: Specific action.
23  // - extra: Extra parameters that will be added to the $_POST.
24  // - match: Callback function for extra fine grained matching. Parameters for the function are:
25  // - router (object): Reference to this component.
26  // - route (string): Route to check against.
27  // - path (string): Path from the request.
28  // - values (array): Paramter values from the match against the route.
29  // - hash: Data for calculating a checksum hash (all optional):
30  // - key (string): Key to use to add the hash to the parameters (defaults to see @hashKey).
31  // - algo (string): Hashing algorithm to use (defaults to see @hashAlgo).
32  // - salt (string): Salt to use for the hashing (defaults to the key of the encrypt component).
33  // - keys (array): Keys of the parameters to include in the hash (defaults to all).
34  // When the provided hash doesn't match with the calculated hash, all parameters (in the keys array) are set to null.
35  public $defaultParams = []; //!< Default params for routes.
36  public $regexShortcuts = [
37  '*' => '\\d+', //integer, 0 and up
38  '+' => '[1-9]\\d*' //integer, 1 and up
39  ];
40  public $hashKey = 'hash'; //!< Default hash key.
41  public $hashAlgo = 'sha256'; //!< Default hash algo.
42 
43  protected $_sharedCacheFile = null; //!< Shared cache file for compiled routes.
44 
45  protected $_cache = null;
46  protected $_save = false;
47  protected $_viewType = null;
48  protected $_controllerName = null;
49  protected $_hashed = false;
50 
51  protected function done(){
52  if($this->_save){
53  if($this->_sharedCacheFile) try{
54  file_put_contents(
55  $temp = $this->_sharedCacheFile . \Rsi\Str::random() . '.tmp',
56  '<?php return ' . var_export($this->_cache,true) . ';'
57  );
58  if(!rename($temp,$this->_sharedCacheFile)) throw new \Exception("Could not rename('$temp','{$this->_sharedCacheFile}')");
59  chmod($this->_sharedCacheFile,0666);
60  }
61  catch(\Exception $e){
62  $this->component('log')->info($e);
63  \Rsi\File::unlink($temp);
64  }
65  else $this->session->cache = $this->_cache;
66  }
67  parent::done();
68  }
69  /**
70  * Retrieve data from the route cache.
71  * @param string $route
72  * @param string $key
73  * @return mixed Data for this route + key.
74  */
75  protected function cache($route,$key){
76  if($this->_cache === null){
77  $this->_cache = [];
78  if($this->_sharedCacheFile) try{
79  $this->_cache = include($this->_sharedCacheFile);
80  if(($this->_cache[self::CACHE_HASH] ?? null) != ($hash = crc32('v4' . serialize($this->routes)))){
81  $this->_cache = [self::CACHE_HASH => $hash,self::CACHE_ROUTES => [],self::CACHE_HASHED => []];
82  $this->session->lastPath = $this->session->lastRoute = null;
83  }
84  }
85  catch(\Throwable $e){
86  \Rsi\File::unlink($this->_sharedCacheFile);
87  $this->component('log')->info($e);
88  }
89  else $this->_cache = $this->session->cache ?: [];
90  }
91  if(!array_key_exists($route,$this->_cache[self::CACHE_ROUTES])) $this->_cache[self::CACHE_ROUTES][$route] = [];
92  return $this->_cache[self::CACHE_ROUTES][$route][$key] ?? null;
93  }
94  /**
95  * Save data to the route cache.
96  * @param string $route
97  * @param string $key
98  * @param mixed $value Data for this route + key.
99  */
100  protected function save($route,$key,$value){
101  $this->_cache[self::CACHE_ROUTES][$route][$key] = $value;
102  $this->_save = true;
103  }
104  /**
105  * Parse a route and generate a mask (regex).
106  * @param string $route
107  * @param string $mask Resulting regex.
108  * @param array $slots Slots for every paramter in the route.
109  */
110  protected function mask($route,&$mask,&$slots){
111  if($cache = $this->cache($route,'mask')) extract($cache);
112  else{
113  preg_match_all('/\\[(.+?)(|\\*.+?)(|:[^\\[\\]]*(\\[.+?\\])*[^\\[\\]]*)\\]/',$mask = '/^' . str_replace('/','\\/',$route) . '$/i',$matches,PREG_SET_ORDER); //parameters in the route
114  $slots = [];
115  foreach($matches as list($full,$key,$enum,$regex)){
116  $slot = 'slot' . count($slots);
117  if($enum){
118  if(substr($enum,0,2) == '*@'){
119  $trans = $this->component('trans');
120  $str_id = substr($enum,2);
121  $enum = [];
122  foreach($trans->langs() as $lang_id => $descr) $enum[$lang_id] = $trans->str("[@$str_id.$lang_id]");
123  }
124  else $enum = \Rsi\Record::explode(substr($enum,1),';','=');
125  }
126  if($regex && array_key_exists($regex = substr($regex,1),$this->regexShortcuts)) $regex = $this->regexShortcuts[$regex];
127  if($component = preg_match('/^@(\\w+)\\.(\\w+)$/',$key,$match)) $key = 'component' . count($slots);
128  $slots[$key] =
129  compact('full','regex','slot','enum') +
130  ($component ? ['component' => $match[1],'property' => $match[2]] : []);
131  $mask = str_replace($full,"(?<$slot>.*?)",$mask);
132  }
133  $mask = strtr($mask,['{' => '(?:','}' => ')?']);
134  foreach($slots as $slot) if($slot['regex'] && extract($slot))
135  $mask = str_replace("<$slot>.*?","<$slot>$regex",$mask);
136  $this->save($route,'mask',compact('mask','slots'));
137  }
138  }
139  /**
140  * Parsed data for route.
141  * @param string $route
142  * @return array Data.
143  */
144  protected function data($route){
145  if($data = $this->cache($route,'data')) return $data;
146  if(!is_array($name = $data = $this->routes[$route])){
147  parse_str(\Rsi\Str::pop($name,'?'),$extra);
148  $action = \Rsi\Str::pop($name,'#');
149  $data = compact('name','action','extra');
150  }
151  $this->save($route,'data',$data);
152  return $data;
153  }
154  /**
155  * Calculate the hash for a set of parameters.
156  * @param array $hash Hash configuration.
157  * @param array $parameters Route parameters (values).
158  * @return string Hash.
159  */
160  protected function hash($hash,$params){
161  unset($params[$hash['key'] ?? $this->hashKey]);
162  ksort($params);
163  return hash(
164  $hash['algo'] ?? $this->hashAlgo,
165  ($hash['salt'] ?? $this->component('encrypt')->key) .
166  print_r(array_key_exists('keys',$hash) ? \Rsi\Record::select($params,$hash['keys']) : $params,true)
167  );
168  }
169  /**
170  * Check if the current controller (+ action) has a hashed version.
171  * @param string $key Cache key.
172  * @return array Keys for the parametes in the hash (or false if not found).
173  */
174  protected function hashed(&$key = null){
175  return array_key_exists($key = strtolower($this->_controllerName . '#' . ($_POST['action'] ?? null)),$this->_cache[self::CACHE_HASHED])
176  ? $this->_cache[self::CACHE_HASHED][$key]
177  : false;
178  }
179  /**
180  * Check if a path matches a route and extract the parameters.
181  * @param string $path Path to check.
182  * @param string $route Route to check against.
183  * @return string Controller name on match.
184  */
185  protected function match($path,$route){
186  if(array_key_exists($route,$this->routes)){
187  $data = $this->data($route);
188  $this->mask($route,$mask,$slots);
189  if(preg_match($mask,$path,$values) && (!array_key_exists('match',$data) || call_user_func($data['match'],$this,$route,$path,$values))){ //path fits this route
190  $name = $data['name'];
191  foreach($slots as $key => $slot) if(array_key_exists($slot['slot'],$values)){
192  $component = $property = null;
193  if(extract($slot) && (($value = $values[$slot]) || !$regex || preg_match("/^$regex$/",$value))){
194  $value = urldecode($values[$slot]);
195  if($component) $component = $this->component($component);
196  if($enum && (!$component || (($enum[$component->get($property)] ?? null) != $value))) $value = array_search($value,$enum);
197  if($component) $component->set($property,$value);
198  else \Rsi\Record::set($_GET,explode('.',$key),$value);
199  if(preg_match('/^\\w+$/',$value)) $name = str_replace("[$key]",$value,$name);
200  }
201  }
202  $this->_controllerName = $name;
203  if($action = $data['action'] ?? null) $_POST['action'] = $action;
204  if(array_key_exists('hash',$data)){
205  $hash = $data['hash'];
206  $keys = $hash['keys'] ?: null;
207  if($this->hashed($cache) === false){
208  $this->_cache[self::CACHE_HASHED][$cache] = $keys;
209  $this->_save = true;
210  }
211  if(hash_equals($this->hash($hash,$_GET),$_GET[$hash['key'] ?? $this->hashKey] ?? null)) $this->_hashed = true;
212  else foreach(($keys ?: array_keys($_GET)) as $key) $_GET[$key] = $_POST[$key] = null;
213  }
214  $_POST = array_merge($_POST,$data['extra'] ?? []);
215  return $this->_controllerName;
216  }
217  }
218  }
219  /**
220  * Determine controller name and type.
221  * If the controller name matches a route, the name will be translated to this route, and parameters present will be added to
222  * the $_GET.
223  * @param string $path If the path is not given, then the path after the script itself ('foo/bar' in '/index.php/foo/bar')
224  * will be used.
225  */
226  public function execute($path = null){
227  $this->_viewType = $this->_controllerName = $this->_hashed = null;
228  if($path === null) $path = $this->pathInfo;
229  if($path = trim(preg_replace('/^' . preg_quote($this->prefix,'/') . '/i','',$path),'/')){
230  if($this->_viewType = strtolower(pathinfo($path,PATHINFO_EXTENSION)))
231  $path = substr($path,0,-(1 + strlen($this->_viewType)));
232  if($path != $this->session->lastPath){
233  $this->session->lastPath = $path;
234  $this->session->lastRoute = null;
235  foreach($this->routes as $route => $data) if($this->match($path,$route)){
236  $this->session->lastRoute = $route;
237  break;
238  }
239  }
240  elseif($route = $this->session->lastRoute) $this->match($path,$route);
241  if(!$this->_controllerName) $this->_controllerName = ucwords(strtolower($path),'/');
242  }
243  if(!$this->_hashed && (($keys = $this->hashed()) !== false)){
244  if($keys) foreach($keys as $key) $_GET[$key] = $_POST[$key] = null;
245  else $_GET = $_POST = [];
246  }
247  $_GET += $this->defaultParams;
248  }
249  /**
250  * Format a (default) route.
251  * @param string $route Base route.
252  * @return string Formatted route.
253  */
254  protected function format($route){
255  return strtolower($route);
256  }
257  /**
258  * Determine a route from a controller name and type.
259  * @param string $controller_name (empty = current)
260  * @param string $type
261  * @param array $params Parameters to use with the route.
262  * @param bool $strict Whether too check the format of the parameters.
263  * @return string Found route. Parameters that were not used in the route are added in the query.
264  */
265  public function reverse($controller_name = null,$type = null,$params = null,$strict = true){
266  if(!$controller_name) $controller_name = $this->controllerName;
267  $link = $this->format($controller_name);
268  $query = $params = ($params ?: []) + $this->defaultParams;
269  foreach($this->routes as $route => $data){
270  $data = $this->data($route);
271  if($data['name'] == $controller_name){
272  if(array_key_exists('extra',$data)) foreach($data['extra'] as $key => $value){
273  if(($query[$key] ?? null) != $value) continue 2;
274  unset($query[$key]);
275  }
276  if(array_key_exists('hash',$data)) $query[$data['hash']['key'] ?? $this->hashKey] = $this->hash($data['hash'],$params);
277  $this->mask($route,$mask,$slots);
278  foreach($slots as $key => $slot) if($slot['component'] ?? null)
279  $query[$key] = $this->component($slot['component'])->get($slot['property']);
280  foreach($query as $key => $value) if(array_key_exists($key,$slots) && is_scalar($value) && extract($slots[$key])){
281  if($enum) $value = $enum[$value] ?? $enum[null] ?? null;
282  if(!$strict || !$regex || preg_match("/^$regex$/",$value)){
283  $route = str_replace($full,urlencode($value),$route);
284  unset($query[$key]);
285  }
286  }
287  do($route = preg_replace('/{[^{}]*?\\[[^\\[]*?\\][^{}]*?}/','',$route,-1,$count)); //remove optional parts with missing params
288  while($count);
289  if(strpos($route,'[') === false){
290  $link = str_replace(['{','}'],'',$route);
291  break;
292  }
293  else $query = $params;
294  }
295  }
296  if($type) $link .= '.' . $type;
297  if($query = http_build_query($query)) $link .= (strpos($link,'?') === false ? '?' : '&') . $query;
298  return $this->prefix . $link;
299  }
300 
301  protected function getControllerName(){
302  if($this->_controllerName === null) $this->execute();
303  return $this->_controllerName;
304  }
305 
306  protected function getPathInfo(){
307  return parse_url($_SERVER['REQUEST_URI'] ?? null,PHP_URL_PATH);
308  }
309 
310  protected function getViewType(){
311  if($this->_controllerName === null) $this->execute();
312  return $this->_viewType;
313  }
314 
315 }
data($route)
Parsed data for route.
Definition: Router.php:144
hashed(&$key=null)
Check if the current controller (+ action) has a hashed version.
Definition: Router.php:174
const CACHE_HASH
Definition: Router.php:7
$hashKey
Default hash key.
Definition: Router.php:40
$prefix
Prefix to add before each route.
Definition: Router.php:11
save($route, $key, $value)
Save data to the route cache.
Definition: Router.php:100
const CACHE_ROUTES
Definition: Router.php:8
cache($route, $key)
Retrieve data from the route cache.
Definition: Router.php:75
hash($hash, $params)
Calculate the hash for a set of parameters.
Definition: Router.php:160
format($route)
Format a (default) route.
Definition: Router.php:254
mask($route, &$mask, &$slots)
Parse a route and generate a mask (regex).
Definition: Router.php:110
reverse($controller_name=null, $type=null, $params=null, $strict=true)
Determine a route from a controller name and type.
Definition: Router.php:265
$routes
Available routes (key = route, value = controller data). Parameters can be present in a route (e...
Definition: Router.php:12
const CACHE_HASHED
Definition: Router.php:9
Basic component class.
Definition: Component.php:8
match($path, $route)
Check if a path matches a route and extract the parameters.
Definition: Router.php:185
$defaultParams
Default params for routes.
Definition: Router.php:35
execute($path=null)
Determine controller name and type.
Definition: Router.php:226
$_sharedCacheFile
Shared cache file for compiled routes.
Definition: Router.php:43
$hashAlgo
Default hash algo.
Definition: Router.php:41
component($name)
Get a component (local or default).
Definition: Component.php:80