chandler/chandler/MVC/Routing/Router.php

273 lines
9.6 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php declare(strict_types=1);
namespace Chandler\MVC\Routing;
use Chandler\Patterns\TSimpleSingleton;
use Chandler\Eventing\EventDispatcher;
use Chandler\Session\Session;
use Chandler\MVC\Exceptions\InterruptedException;
use Chandler\MVC\IPresenter;
use Nette\DI;
class Router
{
const HANDLER_DELIMITER = "%([#@❤]|\->)%";
const ALIAS_REGEX = "%{(\??\!?([A-z]++))}%";
private $url = NULL;
private $routes = [];
private $statics = [];
private $scope = [];
private $events;
protected $types = [
"num" => "(-?\d++)",
"text" => "([A-z0-9]++)",
"slug" => "([A-z0-9А\-_ ]++)",
];
private function __construct()
{
$this->events = EventDispatcher::i();
}
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]])
? NULL
: ($matches[1][1] !== "!" ? "(" : "(?:") . $customAliases[$matches[2]] . ")";
} else {
$replacement = $this->types[$matches[1]];
}
if(!$replacement) {
$exMessage = "Unknown type alias: $matches[1].";
$exMessage .= " (Available options are: " . implode(", ", array_keys($this->types));
if(sizeof($customAliases) > 0)
$exMessage .= " or any of these user-defined aliases: " . implode(", ", array_keys($customAliases)) . ")";
else
$exMessage .= ")";
throw new Exceptions\UnknownTypeAliasException($exMessage);
}
return $replacement;
}, addslashes($route));
if(!is_null($prefix)) {
$regexp = "\\/$prefix\\" . ($route === "/" ? "/" : "/$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
{
$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)
$GLOBALS["csrfCheck"] = true;
} 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
{
$presenter = $this->getPresenter($namespace, $presenterName);
$action = ucfirst($action);
try {
$presenter->onStartup();
$presenter->{"render$action"}(...$parameters);
$presenter->onBeforeRender();
$this->scope += array_merge_recursive($presenter->getTemplateScope(), []); #TODO: add default parameters
#TODO: move this to delegateView
$output = $this->delegateView(
$this->scope["_template"] ?? CHANDLER_EXTENSIONS_ENABLED . "/$namespace/Web/Presenters/templates/$presenterName/$action.xml",
$presenter
);
$presenter->onAfterRender();
} catch(InterruptedException $ex) {}
$presenter->onStop();
$presenter->onDestruction();
$presenter = NULL;
return $output;
}
private function delegateRoute(Route $route, array $matches): string
{
$parameters = [];
foreach($matches as $param)
$parameters[] = is_numeric($param) ? (int) $param : $param;
$this->setCSRFStatus($route);
return $this->delegateController($route->namespace, $route->presenter, $route->action, $parameters);
}
function delegateStatic(string $namespace, string $path): string
{
$static = $static = $this->statics[$namespace];
if(!isset($static)) return "Fatal error: no route";
if(!file_exists($file = "$static/$path"))
return "Fatal error: no resource";
$hash = "W/\"" . hash_file("snefru", $file) . "\"";
if(isset($_SERVER["HTTP_IF_NONE_MATCH"]))
if($_SERVER["HTTP_IF_NONE_MATCH"] === $hash)
exit(header("HTTP/1.1 304"));
header("Content-Type: " . system_extension_mime_type($file));
header("Content-Size: " . filesize($file));
header("ETag: $hash");
readfile($file);
exit;
}
function reverse(string $hotlink, ...$parameters): ?string
{
if(sizeof($j = explode("!", $hotlink)) === 2)
[$namespace, $hotlink] = $j;
else
$namespace = explode("\\", $this->scope["parentModule"])[0];
[$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 push(?string $prefix, string $url, string $namespace, string $presenter, string $action, array $ph): void
{
$route = new Route;
$route->raw = $url;
if(!is_null($prefix))
$route->raw = "/$prefix" . $route->raw;
$route->regex = $this->computeRegExp($url, $ph, $prefix);
$route->namespace = $namespace;
$route->presenter = $presenter;
$route->action = $action;
$this->routes[] = $route;
}
function pushStatic(string $namespace, string $path): void
{
$this->statics[$namespace] = $path;
}
function readRoutes(string $filename, string $namespace, bool $autoprefix = true): void
{
$config = yaml_parse_file($filename);
if(isset($config["static"]))
$this->pushStatic($namespace, CHANDLER_EXTENSIONS_ENABLED . "/$namespace/Web/$config[static]");
foreach($config["routes"] as $route) {
$route = (object) $route;
$placeholders = $route->placeholders ?? [];
[$presenter, $action] = preg_split(Router::HANDLER_DELIMITER, $route->handler);
$this->push($autoprefix ? $namespace : NULL, $route->url, $namespace, $presenter, $action, $placeholders);
}
}
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 execute(string $url, ?string $parentModule = null): ?string
{
$this->url = parse_url($url, PHP_URL_PATH);
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;
}