Reformatting the Router class.

This commit is contained in:
Ilya Bakhlin 2021-12-28 19:55:38 +01:00
parent c89cd500a1
commit 5d7b131240

View file

@ -1,298 +1,266 @@
<?php declare(strict_types=1); <?php
declare(strict_types = 1);
namespace Chandler\MVC\Routing; namespace Chandler\MVC\Routing;
use Chandler\Patterns\TSimpleSingleton;
use Chandler\Eventing\EventDispatcher; use Chandler\Eventing\EventDispatcher;
use Chandler\Session\Session;
use Chandler\MVC\Exceptions\InterruptedException; use Chandler\MVC\Exceptions\InterruptedException;
use Chandler\MVC\IPresenter; use Chandler\MVC\IPresenter;
use Chandler\Patterns\TSimpleSingleton;
use Chandler\Session\Session;
use Nette\DI; use Nette\DI;
use Nette\DI\Config\Adapters\NeonAdapter;
use Nette\DI\Config\Loader;
use SodiumException;
class Router class Router
{ {
const ALIAS_REGEX = "%{(\??\!?([A-z]++))}%";
const HANDLER_DELIMITER = "%([#@❤]|\->)%"; const HANDLER_DELIMITER = "%([#@❤]|\->)%";
const ALIAS_REGEX = "%{(\??\!?([A-z]++))}%";
private $url = NULL;
private $routes = [];
private $statics = [];
private $scope = [];
private $events; private $events;
private $routes = [];
private $scope = [];
private $statics = [];
private $url = null;
protected $types = [ protected $types = [
"num" => "(-?\d++)", "num" => "(-?\d++)",
"text" => "([A-z0-9]++)", "text" => "([A-z0-9]++)",
"slug" => "([A-z0-9А\-_ ]++)", "slug" => "([A-z0-9А\-_ ]++)",
]; ];
private function __construct() private function computeRegExp(string $route, array $customAliases = [], ?string $prefix = null): string
{ {
$this->events = EventDispatcher::i(); $regexp = preg_replace_callback(Router::ALIAS_REGEX, function ($matches) use ($customAliases) {
} if ($matches[1][0] === "?") {
private function computeRegExp(string $route, array $customAliases = [], ?string $prefix = NULL): string
{
$regexp = preg_replace_callback(Router::ALIAS_REGEX, function($matches) use ($customAliases) {
if($matches[1][0] === "?") {
$replacement = !isset($customAliases[$matches[2]]) $replacement = !isset($customAliases[$matches[2]])
? NULL ? null
: ($matches[1][1] !== "!" ? "(" : "(?:") . $customAliases[$matches[2]] . ")"; : ($matches[1][1] !== "!" ? "(" : "(?:") . $customAliases[$matches[2]] . ")";
} else { } else {
$replacement = $this->types[$matches[1]]; $replacement = $this->types[$matches[1]];
} }
if (!$replacement) {
if(!$replacement) { $exMessage = "Unknown type alias: $matches[1].";
$exMessage = "Unknown type alias: $matches[1].";
$exMessage .= " (Available options are: " . implode(", ", array_keys($this->types)); $exMessage .= " (Available options are: " . implode(", ", array_keys($this->types));
if(sizeof($customAliases) > 0) if (sizeof($customAliases) > 0)
$exMessage .= " or any of these user-defined aliases: " . implode(", ", array_keys($customAliases)) . ")"; $exMessage .= " or any of these user-defined aliases: " . implode(", ", array_keys($customAliases)) . ")";
else else
$exMessage .= ")"; $exMessage .= ")";
throw new Exceptions\UnknownTypeAliasException($exMessage); throw new Exceptions\UnknownTypeAliasException($exMessage);
} }
return $replacement; return $replacement;
}, addslashes($route)); }, addslashes($route));
if (!is_null($prefix)) {
if(!is_null($prefix)) {
$regexp = "\\/$prefix\\" . ($route === "/" ? "/" : "/$regexp"); $regexp = "\\/$prefix\\" . ($route === "/" ? "/" : "/$regexp");
} }
return "%^$regexp$%"; return "%^$regexp$%";
} }
function makeCSRFToken(Route $route, string $nonce): string
{
$key = hash("snefru", CHANDLER_ROOT_CONF["security"]["secret"] . bin2hex($nonce));
$data = $route->namespace;
$data .= Session::i()->get("tok", -1);
return hash_hmac("snefru", $data, $key) . "#" . bin2hex($nonce);
}
private function setCSRFStatus(Route $route): void
{
if(CHANDLER_ROOT_CONF["security"]["csrfProtection"] === "disabled") {
$GLOBALS["csrfCheck"] = true;
} else {
$GLOBALS["csrfCheck"] = false;
$hash = ($_GET["hash"] ?? ($_POST["hash"] ?? false));
if($hash !== false) {
$data = explode("#", $hash);
try {
if(!isset($data[0]) || !isset($data[1])) throw new \SodiumException;
[$hash, $nonce] = $data;
if(sodium_memcmp($this->makeCSRFToken($route, hex2bin($nonce)), "$hash#$nonce") === 0) {
if(CHANDLER_ROOT_CONF["security"]["csrfProtection"] === "permissive")
$GLOBALS["csrfCheck"] = true;
else if(CHANDLER_ROOT_CONF["security"]["csrfProtection"] === "strict")
$GLOBALS["csrfCheck"] = parse_url($_SERVER["HTTP_REFERER"], PHP_URL_HOST) === $_SERVER["HTTP_HOST"];
else
trigger_error("Bad value for chandler.security.csrfProtection: disabled, permissive or strict expected.", E_USER_ERROR);
}
} catch(\SodiumException $ex) {}
}
}
$GLOBALS["csrfToken"] = $this->makeCSRFToken($route, openssl_random_pseudo_bytes(4));
}
private function getDI(string $namespace): DI\Container
{
$loader = new DI\ContainerLoader(CHANDLER_ROOT . "/tmp/cache/di_$namespace", true);
$class = $loader->load(function($compiler) use ($namespace) {
$fileLoader = new \Nette\DI\Config\Loader;
$fileLoader->addAdapter("yml", \Nette\DI\Config\Adapters\NeonAdapter::class);
$compiler->loadConfig(CHANDLER_EXTENSIONS_ENABLED . "/$namespace/Web/di.yml", $fileLoader);
});
return new $class;
}
private function getPresenter(string $namespace, string $presenterName): ?IPresenter
{
$di = $this->getDI($namespace);
$services = $di->findByType("\\$namespace\\Web\\Presenters\\$presenterName" . "Presenter", false);
return $di->getService($services[0], false);
}
private function delegateView(string $filename, IPresenter $presenter): string
{
return $presenter->getTemplatingEngine()->renderToString($filename, $this->scope);
}
private function delegateController(string $namespace, string $presenterName, string $action, array $parameters = []): string private function delegateController(string $namespace, string $presenterName, string $action, array $parameters = []): string
{ {
$presenter = $this->getPresenter($namespace, $presenterName); $presenter = $this->getPresenter($namespace, $presenterName);
$action = ucfirst($action); $action = ucfirst($action);
try { try {
$presenter->onStartup(); $presenter->onStartup();
$presenter->{"render$action"}(...$parameters); $presenter->{"render$action"}(...$parameters);
$presenter->onBeforeRender(); $presenter->onBeforeRender();
$this->scope += array_merge_recursive($presenter->getTemplateScope(), []); #TODO: add default parameters $this->scope += array_merge_recursive($presenter->getTemplateScope(), []); #TODO: add default parameters
#TODO: move this to delegateView #TODO: move this to delegateView
$tpl = $this->scope["_template"] ?? "$presenterName/$action.xml"; $tpl = $this->scope["_template"] ?? "$presenterName/$action.xml";
if($tpl[0] !== "/") { if ($tpl[0] !== "/") {
$dir = CHANDLER_EXTENSIONS_ENABLED . "/$namespace/Web/Presenters/templates"; $dir = CHANDLER_EXTENSIONS_ENABLED . "/$namespace/Web/Presenters/templates";
$tpl = "$dir/$tpl"; $tpl = "$dir/$tpl";
if(isset($this->scope["_templatePath"])) if (isset($this->scope["_templatePath"]))
$tpl = str_replace($dir, $this->scope["_templatePath"], $tpl); $tpl = str_replace($dir, $this->scope["_templatePath"], $tpl);
} }
if (!file_exists($tpl)) {
if(!file_exists($tpl)) {
trigger_error("Could not open $tpl as template, falling back.", E_USER_NOTICE); trigger_error("Could not open $tpl as template, falling back.", E_USER_NOTICE);
$tpl = "$presenterName/$action.xml"; $tpl = "$presenterName/$action.xml";
} }
$output = $this->delegateView($tpl, $presenter); $output = $this->delegateView($tpl, $presenter);
$presenter->onAfterRender(); $presenter->onAfterRender();
} catch(InterruptedException $ex) {} } catch (InterruptedException $ex) {
}
$presenter->onStop(); $presenter->onStop();
$presenter->onDestruction(); $presenter->onDestruction();
$presenter = NULL; $presenter = null;
return $output; return $output;
} }
private function delegateRoute(Route $route, array $matches): string private function delegateRoute(Route $route, array $matches): string
{ {
$parameters = []; $parameters = [];
foreach ($matches as $param)
foreach($matches as $param) $parameters[] = is_numeric($param) ? (int)$param : $param;
$parameters[] = is_numeric($param) ? (int) $param : $param;
$this->setCSRFStatus($route); $this->setCSRFStatus($route);
return $this->delegateController($route->namespace, $route->presenter, $route->action, $parameters); return $this->delegateController($route->namespace, $route->presenter, $route->action, $parameters);
} }
private function delegateView(string $filename, IPresenter $presenter): string
{
return $presenter->getTemplatingEngine()->renderToString($filename, $this->scope);
}
private function getDI(string $namespace): DI\Container
{
$loader = new DI\ContainerLoader(CHANDLER_ROOT . "/tmp/cache/di_$namespace", true);
$class = $loader->load(function ($compiler) use ($namespace) {
$fileLoader = new Loader;
$fileLoader->addAdapter("yml", NeonAdapter::class);
$compiler->loadConfig(CHANDLER_EXTENSIONS_ENABLED . "/$namespace/Web/di.yml", $fileLoader);
});
return new $class;
}
private function getPresenter(string $namespace, string $presenterName): ?IPresenter
{
$di = $this->getDI($namespace);
$services = $di->findByType("\\$namespace\\Web\\Presenters\\$presenterName" . "Presenter", false);
return $di->getService($services[0], false);
}
private function setCSRFStatus(Route $route): void
{
if (CHANDLER_ROOT_CONF["security"]["csrfProtection"] === "disabled") {
$GLOBALS["csrfCheck"] = true;
} else {
$GLOBALS["csrfCheck"] = false;
$hash = ($_GET["hash"] ?? ($_POST["hash"] ?? false));
if ($hash !== false) {
$data = explode("#", $hash);
try {
if (!isset($data[0]) || !isset($data[1])) throw new SodiumException;
[$hash, $nonce] = $data;
if (sodium_memcmp($this->makeCSRFToken($route, hex2bin($nonce)), "$hash#$nonce") === 0) {
if (CHANDLER_ROOT_CONF["security"]["csrfProtection"] === "permissive")
$GLOBALS["csrfCheck"] = true;
else if (CHANDLER_ROOT_CONF["security"]["csrfProtection"] === "strict")
$GLOBALS["csrfCheck"] = parse_url($_SERVER["HTTP_REFERER"], PHP_URL_HOST) === $_SERVER["HTTP_HOST"];
else
trigger_error("Bad value for chandler.security.csrfProtection: disabled, permissive or strict expected.", E_USER_ERROR);
}
} catch (SodiumException $ex) {
}
}
}
$GLOBALS["csrfToken"] = $this->makeCSRFToken($route, openssl_random_pseudo_bytes(4));
}
function delegateStatic(string $namespace, string $path): string function delegateStatic(string $namespace, string $path): string
{ {
$static = $static = $this->statics[$namespace]; $static = $static = $this->statics[$namespace];
if(!isset($static)) return "Fatal error: no route"; if (!isset($static)) return "Fatal error: no route";
if (!file_exists($file = "$static/$path"))
if(!file_exists($file = "$static/$path"))
return "Fatal error: no resource"; return "Fatal error: no resource";
$hash = "W/\"" . hash_file("snefru", $file) . "\""; $hash = "W/\"" . hash_file("snefru", $file) . "\"";
if(isset($_SERVER["HTTP_IF_NONE_MATCH"])) if (isset($_SERVER["HTTP_IF_NONE_MATCH"]))
if($_SERVER["HTTP_IF_NONE_MATCH"] === $hash) if ($_SERVER["HTTP_IF_NONE_MATCH"] === $hash)
exit(header("HTTP/1.1 304")); exit(header("HTTP/1.1 304"));
header("Content-Type: " . system_extension_mime_type($file) ?? "text/plain; charset=unknown-8bit"); header("Content-Type: " . system_extension_mime_type($file) ?? "text/plain; charset=unknown-8bit");
header("Content-Size: " . filesize($file)); header("Content-Size: " . filesize($file));
header("ETag: $hash"); header("ETag: $hash");
readfile($file); readfile($file);
exit; exit;
} }
function reverse(string $hotlink, ...$parameters): ?string function execute(string $url, ?string $parentModule = null): ?string
{ {
if(sizeof($j = explode("!", $hotlink)) === 2) $this->url = chandler_escape_url(parse_url($url, PHP_URL_PATH));
[$namespace, $hotlink] = $j; if (!is_null($parentModule)) {
else $GLOBALS["parentModule"] = $parentModule;
$namespace = explode("\\", $this->scope["parentModule"])[0]; $this->scope["parentModule"] = $GLOBALS["parentModule"];
[$presenter, $action] = preg_split(Router::HANDLER_DELIMITER, $hotlink);
foreach($this->routes as $route) {
if($route->namespace !== $namespace || $route->presenter !== $presenter) continue;
if(!is_null($action) && $route->action != $action) continue;
$count = preg_match_all(Router::ALIAS_REGEX, $route->raw);
if($count != sizeof($parameters)) continue;
$i = 0;
return preg_replace_callback(Router::ALIAS_REGEX, function() use ($parameters, &$i) {
return $parameters[$i++];
}, $route->raw);
} }
if (preg_match("%^\/assets\/packages\/static\/([A-z_\\-]++)\/(.++)$%", $this->url, $matches)) {
return NULL; [$j, $namespace, $file] = $matches;
return $this->delegateStatic($namespace, $file);
}
$match = $this->getMatchingRoute($this->url);
if (!$match)
return null;
return $this->delegateRoute(...$match);
} }
function getMatchingRoute(string $url): ?array
{
foreach ($this->routes as $route)
if (preg_match($route->regex, $url, $matches))
return [$route, array_slice($matches, 1)];
return null;
}
function makeCSRFToken(Route $route, string $nonce): string
{
$key = hash("snefru", CHANDLER_ROOT_CONF["security"]["secret"] . bin2hex($nonce));
$data = $route->namespace;
$data .= Session::i()->get("tok", -1);
return hash_hmac("snefru", $data, $key) . "#" . bin2hex($nonce);
}
function push(?string $prefix, string $url, string $namespace, string $presenter, string $action, array $ph): void function push(?string $prefix, string $url, string $namespace, string $presenter, string $action, array $ph): void
{ {
$route = new Route; $route = new Route;
$route->raw = $url; $route->raw = $url;
if(!is_null($prefix)) if (!is_null($prefix))
$route->raw = "/$prefix" . $route->raw; $route->raw = "/$prefix" . $route->raw;
$route->regex = $this->computeRegExp($url, $ph, $prefix);
$route->regex = $this->computeRegExp($url, $ph, $prefix);
$route->namespace = $namespace; $route->namespace = $namespace;
$route->presenter = $presenter; $route->presenter = $presenter;
$route->action = $action; $route->action = $action;
$this->routes[] = $route;
$this->routes[] = $route;
} }
function pushStatic(string $namespace, string $path): void function pushStatic(string $namespace, string $path): void
{ {
$this->statics[$namespace] = $path; $this->statics[$namespace] = $path;
} }
function readRoutes(string $filename, string $namespace, bool $autoprefix = true): void function readRoutes(string $filename, string $namespace, bool $autoprefix = true): void
{ {
$config = chandler_parse_yaml($filename); $config = chandler_parse_yaml($filename);
if (isset($config["static"]))
if(isset($config["static"]))
$this->pushStatic($namespace, CHANDLER_EXTENSIONS_ENABLED . "/$namespace/Web/$config[static]"); $this->pushStatic($namespace, CHANDLER_EXTENSIONS_ENABLED . "/$namespace/Web/$config[static]");
if (isset($config["include"]))
if(isset($config["include"])) foreach ($config["include"] as $include)
foreach($config["include"] as $include)
$this->readRoutes(dirname($filename) . "/$include", $namespace, $autoprefix); $this->readRoutes(dirname($filename) . "/$include", $namespace, $autoprefix);
foreach ($config["routes"] as $route) {
foreach($config["routes"] as $route) { $route = (object)$route;
$route = (object) $route;
$placeholders = $route->placeholders ?? []; $placeholders = $route->placeholders ?? [];
[$presenter, $action] = preg_split(Router::HANDLER_DELIMITER, $route->handler); [$presenter, $action] = preg_split(Router::HANDLER_DELIMITER, $route->handler);
$this->push($autoprefix ? $namespace : null, $route->url, $namespace, $presenter, $action, $placeholders);
$this->push($autoprefix ? $namespace : NULL, $route->url, $namespace, $presenter, $action, $placeholders);
} }
} }
function getMatchingRoute(string $url): ?array function reverse(string $hotlink, ...$parameters): ?string
{ {
foreach($this->routes as $route) if (sizeof($j = explode("!", $hotlink)) === 2)
if(preg_match($route->regex, $url, $matches)) [$namespace, $hotlink] = $j;
return [$route, array_slice($matches, 1)]; else
$namespace = explode("\\", $this->scope["parentModule"])[0];
return NULL; [$presenter, $action] = preg_split(Router::HANDLER_DELIMITER, $hotlink);
foreach ($this->routes as $route) {
if ($route->namespace !== $namespace || $route->presenter !== $presenter) continue;
if (!is_null($action) && $route->action != $action) continue;
$count = preg_match_all(Router::ALIAS_REGEX, $route->raw);
if ($count != sizeof($parameters)) continue;
$i = 0;
return preg_replace_callback(Router::ALIAS_REGEX, function () use ($parameters, &$i) {
return $parameters[$i++];
}, $route->raw);
}
return null;
} }
function execute(string $url, ?string $parentModule = null): ?string private function __construct()
{ {
$this->url = chandler_escape_url(parse_url($url, PHP_URL_PATH)); $this->events = EventDispatcher::i();
if(!is_null($parentModule)) {
$GLOBALS["parentModule"] = $parentModule;
$this->scope["parentModule"] = $GLOBALS["parentModule"];
}
if(preg_match("%^\/assets\/packages\/static\/([A-z_\\-]++)\/(.++)$%", $this->url, $matches)) {
[$j, $namespace, $file] = $matches;
return $this->delegateStatic($namespace, $file);
}
$match = $this->getMatchingRoute($this->url);
if(!$match)
return NULL;
return $this->delegateRoute(...$match);
} }
use TSimpleSingleton; use TSimpleSingleton;
} }