diff --git a/src/opnsense/mvc/app/config/services.php b/src/opnsense/mvc/app/config/services.php deleted file mode 100644 index 96b5ebe9830..00000000000 --- a/src/opnsense/mvc/app/config/services.php +++ /dev/null @@ -1,110 +0,0 @@ -set('config', $config); - -/** - * The URL component is used to generate all kind of urls in the application - */ -$di->set('url', function () use ($config) { - $url = new UrlResolver(); - $url->setBaseUri($config->application->baseUri); - - return $url; -}, true); - -/** - * Setting up the view component - */ -$di->set('view', function () use ($config) { - $view = new View(); - - // if configuration defines more view locations, convert phalcon config items to array - if (is_string($config->application->viewsDir)) { - $view->setViewsDir($config->application->viewsDir); - } else { - $viewDirs = array(); - foreach ($config->application->viewsDir as $viewDir) { - $viewDirs[] = $viewDir; - } - $view->setViewsDir($viewDirs); - } - - $view->registerEngines([ - '.volt' => function ($view) use ($config) { - $volt = new VoltEngine($view, $this); - - $volt->setOptions([ - 'path' => $config->application->cacheDir, - 'separator' => '_' - ]); - - // register additional volt template functions and filters - $volt->getCompiler()->addFunction('theme_file_or_default', 'view_fetch_themed_filename'); - $volt->getCompiler()->addFunction('file_exists', 'view_file_exists'); - $volt->getCompiler()->addFunction('cache_safe', 'view_cache_safe'); - $volt->getCompiler()->addFilter('safe', 'view_html_safe'); - - return $volt; - }, - '.phtml' => 'Phalcon\Mvc\View\Engine\Php', - ]); - - return $view; -}, true); - -/** - * If the configuration specify the use of metadata adapter use it or use memory otherwise - */ -$di->set('modelsMetadata', function () { - return new MetaDataAdapter(); -}); - -/** - * Start the session the first time some component request the session service - */ -$di->setShared('session', function () { - $session = new Manager(); - $files = new Stream([ - 'savePath' => session_save_path(), - 'prefix' => 'sess_', - ]); - $session->setAdapter($files); - $session->start(); - // Set session response cookie, unfortunately we need to read the config here to determine if secure option is - // a valid choice. - $cnf = Config::getInstance(); - if ((string)$cnf->object()->system->webgui->protocol == 'https') { - $secure = true; - } else { - $secure = false; - } - setcookie(session_name(), session_id(), 0, '/', '', $secure, true); - - return $session; -}); - -/** - * Setup router - */ -$di->set('router', function () use ($config, $di) { - $routing = new Routing($config->application->controllersDir, "ui"); - $router = $routing->getRouter(); - $router->setDI($di); - $router->handle($_SERVER['REQUEST_URI']); - return $router; -}); diff --git a/src/opnsense/mvc/app/config/services_api.php b/src/opnsense/mvc/app/config/services_api.php deleted file mode 100644 index e00577ad1bc..00000000000 --- a/src/opnsense/mvc/app/config/services_api.php +++ /dev/null @@ -1,127 +0,0 @@ -set('config', $config); - -$di->set('view', function () use ($config) { - // return a empty view - $view = new View(); - $view->disable(); - return $view; -}); - -/** - * The URL component is used to generate all kind of urls in the application - */ -$di->set('url', function () use ($config) { - $url = new UrlResolver(); - $url->setBaseUri($config->application->baseUri); - - return $url; -}, true); - -/** - * Start the session the first time some component request the session service - */ -$di->setShared('session', function () { - $session = new Manager(); - $files = new Stream([ - 'savePath' => session_save_path(), - 'prefix' => 'sess_', - ]); - $session->setAdapter($files); - $session->start(); - // Set session response cookie, unfortunately we need to read the config here to determine if secure option is - // a valid choice. - $cnf = Config::getInstance(); - if ((string)$cnf->object()->system->webgui->protocol == 'https') { - $secure = true; - } else { - $secure = false; - } - setcookie(session_name(), session_id(), 0, '/', '', $secure, true); - - return $session; -}); - - -/** - * Setup router - */ -$di->set('router', function () use ($config, $di) { - $routing = new Routing($config->application->controllersDir, "api"); - $router = $routing->getRouter(); - $router->setDI($di); - $router->handle($_SERVER['REQUEST_URI']); - return $router; -}); - -// exception handling -$di->get('eventsManager')->attach("dispatch:beforeException", function ($event, $dispatcher, $exception) { - switch ($exception->getCode()) { - case Phalcon\Dispatcher\Exception::EXCEPTION_HANDLER_NOT_FOUND: - // send to error action on default index controller - $dispatcher->forward(array( - 'controller' => 'index', - 'namespace' => '\OPNsense\Base', - 'action' => 'handleError', - 'params' => array( - 'message' => 'controller ' . $dispatcher->getControllerClass() . ' not found', - 'sender' => 'API' - ) - )); - return false; - case Phalcon\Dispatcher\Exception::EXCEPTION_ACTION_NOT_FOUND: - // send to error action on default index controller - $dispatcher->forward(array( - 'controller' => 'index', - 'namespace' => '\OPNsense\Base', - 'action' => 'handleError', - 'params' => array( - 'message' => 'action ' . $dispatcher->getActionName() . ' not found', - 'sender' => 'API' - ) - )); - return false; - } -}); -$di->get('dispatcher')->setEventsManager($di->get('eventsManager')); diff --git a/src/opnsense/mvc/app/controllers/OPNsense/Base/ApiControllerBase.php b/src/opnsense/mvc/app/controllers/OPNsense/Base/ApiControllerBase.php index 34291034afa..1efc4f4fa3c 100644 --- a/src/opnsense/mvc/app/controllers/OPNsense/Base/ApiControllerBase.php +++ b/src/opnsense/mvc/app/controllers/OPNsense/Base/ApiControllerBase.php @@ -31,6 +31,7 @@ use OPNsense\Core\ACL; use OPNsense\Core\Backend; use OPNsense\Core\Config; +use OPNsense\Mvc\Security; use OPNsense\Auth\AuthenticationFactory; /** @@ -201,7 +202,7 @@ private function parseJsonBodyData() switch (strtolower(str_replace(' ', '', $this->request->getHeader('CONTENT_TYPE')))) { case 'application/json': case 'application/json;charset=utf-8': - $jsonRawBody = $this->request->getJsonRawBody(true); + $jsonRawBody = $this->request->getJsonRawBody(); if (empty($this->request->getRawBody()) && empty($jsonRawBody)) { return "Invalid JSON syntax"; } @@ -366,8 +367,10 @@ public function beforeExecuteRoute($dispatcher) } // check for valid csrf on post requests - $csrf_token = $this->request->getHeader('X_CSRFTOKEN'); - $csrf_valid = $this->security->checkToken(null, $csrf_token, false); + $csrf_valid = (new Security($this->session, $this->request))->checkToken( + null, + $this->request->getHeader('X_CSRFTOKEN') + ); if ( ($this->request->isPost() || @@ -404,6 +407,9 @@ public function afterExecuteRoute($dispatcher) } else { $this->response->setContent(htmlspecialchars(json_encode($data), ENT_NOQUOTES)); } + } elseif (is_string($data)) { + // XXX: fallback, controller returned data as string. a deprecation message might be an option here. + $this->response->setContent($data); } return $this->response->send(); diff --git a/src/opnsense/mvc/app/controllers/OPNsense/Base/ControllerRoot.php b/src/opnsense/mvc/app/controllers/OPNsense/Base/ControllerRoot.php index afb26acf2de..afdb931726c 100644 --- a/src/opnsense/mvc/app/controllers/OPNsense/Base/ControllerRoot.php +++ b/src/opnsense/mvc/app/controllers/OPNsense/Base/ControllerRoot.php @@ -30,7 +30,7 @@ use OPNsense\Core\Config; use OPNsense\Core\Syslog; -use Phalcon\Mvc\Controller; +use OPNsense\Mvc\Controller; use Phalcon\Translate\InterpolatorFactory; use OPNsense\Core\ACL; diff --git a/src/opnsense/mvc/app/controllers/OPNsense/Base/IndexController.php b/src/opnsense/mvc/app/controllers/OPNsense/Base/IndexController.php index 34c8bfd095b..a2ef05ccf98 100644 --- a/src/opnsense/mvc/app/controllers/OPNsense/Base/IndexController.php +++ b/src/opnsense/mvc/app/controllers/OPNsense/Base/IndexController.php @@ -42,28 +42,4 @@ class IndexController extends ControllerBase public function indexAction() { } - - /** - * log or send error message - * @param string $message error message - * @param string|null $sender - * @return bool - */ - public function handleErrorAction($message = null, $sender = null) - { - // API call, send error to user - if ($sender == 'API') { - $this->response->setStatusCode(400, "Bad Request"); - $this->response->setContentType('application/json', 'UTF-8'); - $this->response->setJsonContent( - array('message' => $message, - 'status' => 400 - ) - ); - } else { - $this->getLogger()->error($message); - $this->response->redirect("/", true); - } - return false; - } } diff --git a/src/opnsense/mvc/app/controllers/OPNsense/Syslog/Api/SettingsController.php b/src/opnsense/mvc/app/controllers/OPNsense/Syslog/Api/SettingsController.php index 329902aa392..c2111113d52 100644 --- a/src/opnsense/mvc/app/controllers/OPNsense/Syslog/Api/SettingsController.php +++ b/src/opnsense/mvc/app/controllers/OPNsense/Syslog/Api/SettingsController.php @@ -28,7 +28,6 @@ namespace OPNsense\Syslog\Api; -use Phalcon\Filter\Filter; use OPNsense\Base\ApiMutableModelControllerBase; use OPNsense\Core\Backend; use OPNsense\Core\Config; diff --git a/src/opnsense/mvc/app/library/OPNsense/Core/Routing.php b/src/opnsense/mvc/app/library/OPNsense/Core/Routing.php deleted file mode 100644 index e23bbc1ae19..00000000000 --- a/src/opnsense/mvc/app/library/OPNsense/Core/Routing.php +++ /dev/null @@ -1,187 +0,0 @@ -rootDir = $root; - $this->router = new Router(false); - // defaults - $this->router->setDefaultController('index'); - $this->router->setDefaultAction('index'); - $this->router->add('/', array("controller" => 'index',"action" => 'index')); - - $this->type = $type; - switch ($this->type) { - case "ui": - $this->router->setDefaultNamespace('OPNsense\Core'); - break; - case "api": - $this->router->setDefaultNamespace('OPNsense\Core\Api'); - $this->suffix = "/Api"; - break; - default: - throw new \Exception("Invalid type " . $type); - } - - $this->setup(); - } - - /** - * setup routing - */ - private function setup() - { - // - // probe registered API modules and create a namespace map - // for example, OPNsense\Core\Api will be mapped at http(s):\\host\core\.. - // - // if the glob for probing the directories turns out to be too slow, - // we should consider some kind of caching here - // - $registered_modules = array(); - $rootDirs = is_object($this->rootDir) || is_array($this->rootDir) ? $this->rootDir : array($this->rootDir); - foreach ($rootDirs as $rootDir) { - foreach (glob($rootDir . "*", GLOB_ONLYDIR) as $namespace_base) { - foreach (glob($namespace_base . "/*", GLOB_ONLYDIR) as $module_base) { - if (is_dir($module_base . $this->suffix)) { - $basename = strtolower(basename($module_base)); - $api_base = $module_base . $this->suffix; - $namespace_name = str_replace('/', '\\', str_replace($rootDir, '', $api_base)); - if (empty($registered_modules[$basename])) { - $registered_modules[$basename] = array(); - } - // always place OPNsense components on top - $sortOrder = stristr($module_base, '/OPNsense/') ? "0" : count($registered_modules[$basename]) + 1; - $registered_modules[$basename][$sortOrder] = array(); - $registered_modules[$basename][$sortOrder]['namespace'] = $namespace_name; - $registered_modules[$basename][$sortOrder]['path'] = $api_base; - ksort($registered_modules[$basename]); - } - } - } - } - - // add routing for all controllers, using the following convention: - // \module\controller\action\params - // where module is mapped to the corresponding namespace - foreach ($registered_modules as $module_name => $module_configs) { - $namespace = array_shift($module_configs)['namespace']; - $this->router->add("/{$this->type}/" . $module_name, array( - "namespace" => $namespace, - )); - - $this->router->add("/{$this->type}/" . $module_name . "/:controller", array( - "namespace" => $namespace, - "controller" => 1 - )); - - $this->router->add("/{$this->type}/" . $module_name . "/:controller/:action", array( - "namespace" => $namespace, - "controller" => 1, - "action" => 2 - )); - - - $this->router->add("/{$this->type}/" . $module_name . "/:controller/:action/:params", array( - "namespace" => $namespace, - "controller" => 1, - "action" => 2, - "params" => 3 - )); - - // In case we have overlapping modules, map additional controllers on top. - // This can normally only happens with 3rd party plugins hooking into standard functionality - if (count($module_configs) > 0) { - foreach ($module_configs as $module_config) { - foreach (glob($module_config['path'] . "/*.php") as $filename) { - // extract controller name and bind static in routing table - $controller = strtolower(str_replace('Controller.php', '', basename($filename))); - $this->router->add("/{$this->type}/{$module_name}/{$controller}/:action", array( - "namespace" => $module_config['namespace'], - "controller" => $controller, - "action" => 1 - )); - $this->router->add("/{$this->type}/{$module_name}/{$controller}/:action/:params", array( - "namespace" => $module_config['namespace'], - "controller" => $controller, - "action" => 1, - "params" => 2 - )); - } - } - } - $this->router->removeExtraSlashes(true); - } - } - - /** - * @return Router - */ - public function getRouter() - { - return $this->router; - } -} diff --git a/src/opnsense/mvc/app/library/OPNsense/Core/SanitizeFilter.php b/src/opnsense/mvc/app/library/OPNsense/Core/SanitizeFilter.php index 98ebab49bd3..e352fd5468f 100644 --- a/src/opnsense/mvc/app/library/OPNsense/Core/SanitizeFilter.php +++ b/src/opnsense/mvc/app/library/OPNsense/Core/SanitizeFilter.php @@ -97,4 +97,9 @@ protected function filter_pkgname($input) { return preg_replace('/[^0-9a-zA-Z._-]/', '', $input); } + + protected function filter_striptags($input) + { + return strip_tags($input); + } } diff --git a/src/opnsense/mvc/app/library/OPNsense/Mvc/Controller.php b/src/opnsense/mvc/app/library/OPNsense/Mvc/Controller.php new file mode 100644 index 00000000000..76d2ec49fe5 --- /dev/null +++ b/src/opnsense/mvc/app/library/OPNsense/Mvc/Controller.php @@ -0,0 +1,88 @@ +view = new View(); + $viewDirs = []; + foreach ((array)$appcfg->application->viewsDir as $viewDir) { + $viewDirs[] = $viewDir; + } + $this->view->setViewsDir($viewDirs); + $this->view->setDI(new FactoryDefault()); + $this->view->registerEngines([ + '.volt' => function ($view) use ($appcfg) { + $volt = new VoltEngine($view); + $volt->setOptions([ + 'path' => $appcfg->application->cacheDir, + 'separator' => '_' + ]); + $volt->getCompiler()->addFunction('theme_file_or_default', 'view_fetch_themed_filename'); + $volt->getCompiler()->addFunction('file_exists', 'view_file_exists'); + $volt->getCompiler()->addFunction('cache_safe', 'view_cache_safe'); + $volt->getCompiler()->addFilter('safe', 'view_html_safe'); + return $volt; + }] + ); + } + + /** + * @param Dispatcher $dispatcher + * @return void + */ + public function afterExecuteRoute(Dispatcher $dispatcher) + { + $this->view->start(); + $this->view->processRender('', ''); + $this->view->finish(); + + $this->response->setContent($this->view->getContent()); + } + + public function initialize(){} +} diff --git a/src/opnsense/mvc/app/library/OPNsense/Mvc/Dispatcher.php b/src/opnsense/mvc/app/library/OPNsense/Mvc/Dispatcher.php new file mode 100644 index 00000000000..7ce6642eb88 --- /dev/null +++ b/src/opnsense/mvc/app/library/OPNsense/Mvc/Dispatcher.php @@ -0,0 +1,178 @@ +namespace = $namespace; + $this->controller = $controller; + $this->action = $action; + $this->parameters = $parameters; + } + + /** + * Resolve controller class and inspect method to call, except when the target controller offers a __call + * hook in which case we expect the target to offer proper error handling + * @throws ClassNotFoundException when controller class can not be found + * @throws MethodNotFoundException when controller method can not be found + * @throws ParameterMismatchException when expected required parameters do not match offered ones + */ + protected function resolve(): void + { + if (isset($this->controllerClass)) { + // already resolved + return; + } + $clsname = $this->namespace . "\\" . $this->controller; + try { + $this->controllerClass = new ReflectionClass($clsname); + } catch (ReflectionException) { + throw new ClassNotFoundException(sprintf("%s not found", $clsname)); + } + if (!$this->controllerClass->isInstantiable()) { + throw new ClassNotFoundException(sprintf("%s not found", $clsname)); + } + if (!$this->controllerClass->hasMethod($this->action) && $this->controllerClass->hasMethod('__call')) { + // dynamic class, we can't probe the method and its expected parameters. + return; + } + if (!$this->controllerClass->hasMethod($this->action)) { + throw new MethodNotFoundException(sprintf("%s -> %s not found", $clsname, $this->action)); + } + try { + $actionMethod = $this->controllerClass->getMethod($this->action); + } catch (ReflectionException) { + throw new MethodNotFoundException(sprintf("%s -> %s not found", $clsname, $this->action)); + } + $pcount = 0; + foreach ($actionMethod->getParameters() as $param) { + if ($param->isOptional()) { + break; + } + $pcount++; + } + if ($pcount > count($this->parameters)) { + unset($this->controllerClass); + throw new ParameterMismatchException(sprintf( + "%s -> %s parameter mismatch (expected %d, got %d)", + $clsname, + $this->action, + $pcount, + count($this->parameters) + )); + } + } + + /** + * test if controller action method is callable with the parameters provided + * @return bool + */ + public function canExecute() : bool + { + try { + $this->resolve(); + return true; + } catch(Exception){ + return false; + } + } + + /** + * Dispatch (execute) controller action method + * @param Request $request http request object + * @param Response $response http response object + * @param Session $session session object + * @return bool + * @throws ClassNotFoundException when controller class can not be found + * @throws MethodNotFoundException when controller method can not be found + * @throws ParameterMismatchException when expected required parameters do not match offered ones + * @throws ReflectionException when invoke fails + */ + public function dispatch(Request $request, Response $response, Session $session) : bool + { + $this->resolve(); + + $controller = $this->controllerClass->newInstance(); + $controller->session = $session; + $controller->request = $request; + $controller->response = $response; + $controller->security = new Security($session, $request); + + $controller->initialize(); + + if ($controller->beforeExecuteRoute($this) === false) { + return false; + } + $this->returnedValue = $controller->{$this->action}(...$this->parameters); + $session->close(); + $controller->afterExecuteRoute($this); + return true; + } + + /** + * @return array|string|null response + */ + public function getReturnedValue(): array|string|null + { + return $this->returnedValue; + } + + /** + * XXX: remove call from ControllerBase, seems like a workaround for a specific phalcon issue back in 2015 + * @return bool + */ + public function wasForwarded(): bool + { + return false; + } +} diff --git a/src/opnsense/mvc/app/library/OPNsense/Mvc/Exceptions/ClassNotFoundException.php b/src/opnsense/mvc/app/library/OPNsense/Mvc/Exceptions/ClassNotFoundException.php new file mode 100644 index 00000000000..88a8bd4b051 --- /dev/null +++ b/src/opnsense/mvc/app/library/OPNsense/Mvc/Exceptions/ClassNotFoundException.php @@ -0,0 +1,31 @@ +headers[$name] = $value; + return $this; + } + + /** + * https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml + * @param int $http_response_code response code according to the iana list + * @return $this + */ + public function setResponseCode(int $http_response_code): Headers + { + $this->http_response_code = $http_response_code; + return $this; + } + + /** + * @return http response code + */ + public function getResponseCode(): int|null + { + return $this->http_response_code; + } + + /** + * @param string $header to unset + * @return $this + */ + public function remove(string $header): Headers + { + if (isset($this->headers[$header])) { + unset($this->headers[$header]); + } + return $this; + } + + /** + * @return array list of all provided headers + */ + public function getHeaders(): array + { + return $this->headers; + } + + /** + * @param string $header header by name + * @return mixed|null + */ + public function get(string $header): string|null + { + return $this->headers[$header] ?? null; + } + + /** + * flush all headers + * @return $this + */ + public function reset(): Headers + { + $this->headers = []; + return $this; + } + + /** + * send headers to the client including http response code + * @return void + */ + public function send(): void + { + if (!headers_sent()) { + if ($this->http_response_code !== null) { + http_response_code($this->http_response_code); + } + foreach ($this->headers as $name => $value) { + header("$name: $value"); + } + } + } +} diff --git a/src/opnsense/mvc/app/library/OPNsense/Mvc/Request.php b/src/opnsense/mvc/app/library/OPNsense/Mvc/Request.php new file mode 100644 index 00000000000..09025090d3c --- /dev/null +++ b/src/opnsense/mvc/app/library/OPNsense/Mvc/Request.php @@ -0,0 +1,136 @@ +getMethod() == 'POST'; + } + + public function isGet(): bool + { + return $this->getMethod() == 'GET'; + } + + public function isPut(): bool + { + return $this->getMethod() == 'PUT'; + } + + public function isDelete(): bool + { + return $this->getMethod() == 'DELETE'; + } + + public function isHead(): bool + { + return $this->getMethod() == 'HEAD'; + } + + public function isPatch(): bool + { + return $this->getMethod() == 'PATCH'; + } + + public function isOptions(): bool + { + return $this->getMethod() == 'OPTIONS'; + } + + public function getRawBody(): string + { + if (empty($this->rawBody)) { + $this->rawBody = file_get_contents("php://input"); + } + return $this->rawBody; + } + + public function has(string $name): bool + { + return isset($_REQUEST[$name]); + } + + public function hasPost(string $name): bool + { + return isset($_POST[$name]); + } + + public function getPost(string $name, ?string $filter = null, ?string $defaultValue = null) + { + $value = isset($_POST[$name]) ? $_POST[$name] : $defaultValue; + if ($filter !== null && $value !== null) { + $value = (new SanitizeFilter())->sanitize($value, $filter); + } + return $value; + } + + public function get(string $name, ?string $filter = null, ?string $defaultValue = null) + { + $value = isset($_REQUEST[$name]) ? $_REQUEST[$name] : $defaultValue; + if ($filter !== null) { + $value = (new SanitizeFilter())->sanitize($value, $filter); + } + return $value; + } + + public function getJsonRawBody(): stdClass| array| bool + { + return json_decode($this->getRawBody(), true) ?? false; + } +} diff --git a/src/opnsense/mvc/app/library/OPNsense/Mvc/Response.php b/src/opnsense/mvc/app/library/OPNsense/Mvc/Response.php new file mode 100644 index 00000000000..1584870c300 --- /dev/null +++ b/src/opnsense/mvc/app/library/OPNsense/Mvc/Response.php @@ -0,0 +1,129 @@ +headers = new Headers(); + } + + public function getHeaders(): Headers + { + return $this->headers; + } + + public function setContent(string $content): void + { + $this->content = $content; + } + + public function getContent(): string + { + return $this->content; + } + + /** + * @param string $contentType content type to offer the client + * @param string|null $charset optional characterset + * @return void + */ + public function setContentType(string $contentType, ?string $charset): void + { + if (!empty($charset)) { + $contentType .= '; charset=' . $charset; + } + $this->headers->set('Content-Type', $contentType); + } + + /** + * @param int $statusCode http status code + * @param string|null $message backwards compatibility, messages are ignored + * @return void + */ + public function setStatusCode(int $statusCode, ?string $message = null): void + { + $this->headers->setResponseCode($statusCode); + } + + /** + * @return int|null status code + */ + public function getStatusCode(): int|null + { + return $this->headers->getResponseCode(); + } + + /** + * @return void + * @throws Exception when already send + */ + public function send(): void + { + if ($this->sent) { + throw new Exception('Response Already Sent'); + } + $this->headers->send(); + + echo $this->content; + $this->sent = true; + } + + /** + * @return bool if response was already sent to the client + */ + public function isSent(): bool + { + return $this->sent; + } + + /** + * @param string $location location to forward request to + * @param bool $externalRedirect backwards compatibility + * @param int $statusCode HTTP status code + * @return void + */ + public function redirect(string $location, bool $externalRedirect = true, int $statusCode = 302): void + { + $this->setStatusCode($statusCode); + $this->headers->set('Location', $location); + } + + public function setHeader(string $name, string $value): void + { + $this->headers->set($name, $value); + } +} diff --git a/src/opnsense/mvc/app/library/OPNsense/Mvc/Router.php b/src/opnsense/mvc/app/library/OPNsense/Mvc/Router.php new file mode 100644 index 00000000000..c9745d2275e --- /dev/null +++ b/src/opnsense/mvc/app/library/OPNsense/Mvc/Router.php @@ -0,0 +1,179 @@ +prefix = $prefix; + $this->namespace_suffix = $namespace_suffix; + } + + /** + * probe for namespace in specified AppConfig controllersDir + * @param string $namespace base namespace to search for (without vendor) + * @param string $controller controller class name + * @return string|null namespace with vendor when found + */ + private function resolveNamespace(?string $namespace, ?string $controller): string|null + { + if (empty($namespace) || empty($controller)) { + return null; + } + $appconfig = new AppConfig(); + foreach ((array)$appconfig->application->controllersDir as $controllersDir) { + // sort OPNsense namespace on top + $dirs = glob($controllersDir. "/*", GLOB_ONLYDIR); + usort($dirs, function($a, $b){ + if (basename($b) == 'OPNsense') { + return 1; + } else { + return strcmp(strtolower($a), strtolower($b)); + } + }) ; + foreach ($dirs as $dirname) { + $basename = basename($dirname); + $new_namespace = "$basename\\$namespace"; + if (!empty($this->namespace_suffix)) { + $expected_filename = "$dirname/$namespace/$this->namespace_suffix/$controller.php"; + $new_namespace .= "\\" . $this->namespace_suffix; + } else { + $expected_filename = "$dirname/$namespace/$controller.php"; + } + if (is_file($expected_filename)) { + return $new_namespace; + } + } + } + return null; + } + + /** + * Route a request + * @param string $uri + * @param array $default list of routing defaults (controller, acount) + * @return Response to be rendered + * @throws ClassNotFoundException + * @throws MethodNotFoundException + * @throws ParameterMismatchException + * @throws InvalidUriException + * @throws ReflectionException when invoke fails + */ + public function routeRequest(string $uri, array $defaults = []) : Response + { + $path = parse_url($uri)['path']; + + if (!str_starts_with($path, $this->prefix)) { + throw new InvalidUriException("Invalid route path: " . $uri); + } + + // extract target (base)namespace, controller and action + $targetAndParameters = $this->parsePath(substr($path, strlen($this->prefix)), $defaults); + + $controller = $targetAndParameters['controller']; + // resolve full namespace (including vendor) when we know which controller to access. + $namespace = $this->resolveNamespace($targetAndParameters['namespace'], $controller); + $action = $targetAndParameters['action']; + $parameters = $targetAndParameters['parameters']; + + if ($action === null || $controller === null || $namespace === null) { + throw new InvalidUriException("Invalid route path, no action, controller, and / or namespace: " . $uri); + } + + $dispatcher = new Dispatcher($namespace, $controller, $action, $parameters); + + return $this->performRequest($dispatcher); + } + + /** + * @param Dispatcher $dispatcher request dispatcher + * @return Response object + * @throws ClassNotFoundException + * @throws MethodNotFoundException + * @throws ParameterMismatchException + * @throws ReflectionException when invoke fails + */ + private function performRequest(Dispatcher $dispatcher) : Response + { + $session = new Session(); + $request = new Request(); + $response = new Response(); + + $dispatcher->dispatch($request, $response, $session); + return $response; + } + + + /** + * @param string $path path to extract + * @param array $default list of routing defaults (controller, acount) + * @return array containing expected controller action + */ + private function parsePath(string $path, array $defaults) : array { + $pathElements = explode("/", rtrim($path, '/')); + $result = [ + "namespace" => null, + "controller" => null, + "action" => null, + "parameters" => [] + ]; + foreach ($defaults as $key => $val) { + $result[$key] = $val; + } + + foreach($pathElements as $idx => $element){ + if ($idx == 0) { + $result["namespace"] = str_replace('_', '', ucwords($element,'_')); + } elseif ($idx == 1) { + $result["controller"] = str_replace('_', '', ucwords($element,'_')) . 'Controller'; + } elseif ($idx == 2) { + $result["action"] = lcfirst(str_replace('_', '', ucwords($element,'_'))) . "Action"; + } else { + $result["parameters"][] = $element; + } + } + + return $result; + } +} diff --git a/src/opnsense/mvc/app/library/OPNsense/Mvc/Security.php b/src/opnsense/mvc/app/library/OPNsense/Mvc/Security.php new file mode 100644 index 00000000000..948a8d50f64 --- /dev/null +++ b/src/opnsense/mvc/app/library/OPNsense/Mvc/Security.php @@ -0,0 +1,99 @@ +session = $session; + $this->request = $request; + } + + /** + * @param bool $new when not found, create new + * @return string|null token + */ + public function getToken(bool $new=true): ?string + { + $token = $this->session->get('$PHALCON/CSRF$'); + if (empty($token) && $new) { + $token = $this->base64Safe(); + $this->session->set('$PHALCON/CSRF$', $token); + } + return $token; + } + + /** + * @param bool $new when not found, create new + * @return string|null name of the token + */ + public function getTokenKey(bool $new=true): ?string + { + $token = $this->session->get('$PHALCON/CSRF/KEY$'); + if (empty($token) && $new) { + $token = $this->base64Safe(); + $this->session->set('$PHALCON/CSRF/KEY$', $token); + } + return $token; + } + + /** + * @param string|null $tokenKey parameter name used to store the csrf token + * @param string|null $tokenValue value to check against + * @return bool true when CSRF token is valid + */ + public function checkToken(?string $tokenKey = null, ?string $tokenValue = null): bool + { + + $key = $tokenKey ?? $this->getTokenKey(false); + if (empty($key)) { + return false; + } + $value = $tokenValue ?? $_POST[$tokenKey]; + return !empty($value) && $value === $this->getToken(); + } +} diff --git a/src/opnsense/mvc/app/library/OPNsense/Mvc/Session.php b/src/opnsense/mvc/app/library/OPNsense/Mvc/Session.php new file mode 100644 index 00000000000..99aa0903edf --- /dev/null +++ b/src/opnsense/mvc/app/library/OPNsense/Mvc/Session.php @@ -0,0 +1,131 @@ +object()->system->webgui->protocol == 'https', + true + ); + session_start(); + $shouldClose = true; + } + $this->payload = $_SESSION; + if ($shouldClose) { + session_abort(); + } + } + + /** + * @param string $name parameter name + * @return bool when found + */ + public function has(string $name): bool + { + return isset($this->payload[$name]); + } + + /** + * @param string $name parameter name + * @param string|null $default_value default value when not found + * @return string|null + */ + public function get(string $name, ?string $default_value = null): string|null + { + return $this->payload[$name] ?? $default_value; + } + + /** + * update (cached) session and keep track of changes for concurrency. + * @param $name + * @param $value + * @return void + */ + public function set($name, $value): void + { + $this->payload[$name] = $value; + $this->changed_fields[] = $name; + } + + /** + * Remove parameter from (cached) session and keep track of changes + * @param string $name parameter name + * @return void + */ + public function remove(string $name): void + { + unset($this->payload[$name]); + $this->changed_fields[] = $name; + } + + /** + * destroy session object and flush changes to disk + */ + function close(): void + { + if (!empty($this->changed_fields)) { + if (session_status() == PHP_SESSION_NONE) { + session_start(); + } + foreach ($this->changed_fields as $name) { + if (!isset($this->payload[$name])) { + unset($_SESSION[$name]); + } else { + $_SESSION[$name] = $this->payload[$name]; + } + } + session_write_close(); + } + } +} diff --git a/src/opnsense/www/api.php b/src/opnsense/www/api.php index 6c69161f033..b412feb2d3d 100644 --- a/src/opnsense/www/api.php +++ b/src/opnsense/www/api.php @@ -1,56 +1,37 @@ handle($_SERVER['REQUEST_URI'])->getContent(); -} catch (\Error | \Exception $e) { - $unbuffered = strpos($e->getMessage(), 'ob_end_clean') !== false; - - if (!is_a($e, 'OPNsense\Base\UserException') && !$unbuffered) { - error_log($e); - } - - if ($unbuffered) { - /** - * if buffer has been deleted already, we must assume the implementation - * has already sent headers and we cannot send them again. - */ - return; +function error_output($http_code, $e, $user_message) +{ + $response = []; + if (!file_exists('/var/run/development')){ + $response['errorMessage'] = $user_message; + } else { + $response['errorMessage'] = $e->getMessage(); + $response['errorTrace'] = $e->getTraceAsString(); } - - $response = [ - 'errorMessage' => $e->getMessage(), - 'errorTrace' => $e->getTraceAsString(), - ]; - if (method_exists($e, 'getTitle')) { $response['errorTitle'] = $e->getTitle(); - } else { - $response['errorTitle'] = gettext('An API exception occurred'); - $response['errorMessage'] = $e->getFile() . ':' . $e->getLine() . ': ' . $response['errorMessage']; } - - header('HTTP', true, 500); + header('HTTP', true, $http_code); header("Content-Type: application/json;charset=utf-8"); - echo json_encode($response, JSON_UNESCAPED_SLASHES); } + + +try { + $config = include __DIR__ . "/../mvc/app/config/config.php"; + include __DIR__ . "/../mvc/app/config/loader.php"; + + $router = new OPNsense\Mvc\Router('/api/', 'Api'); + $response = $router->routeRequest($_SERVER['REQUEST_URI']); + if (!$response->isSent()) { + $response->send(); + } +} catch (\OPNsense\Base\UserException $e) { + error_output(500, $e, $e->getMessage()); +} catch (\OPNsense\Mvc\Exceptions\DispatchException) { + error_output(404, $e, gettext('Endpoint not found')); +} catch (\Error | \Exception $e) { + error_output(500, $e, gettext('Unexpected error, check log for details')); + error_log($e); +} diff --git a/src/opnsense/www/index.php b/src/opnsense/www/index.php index b4d7510e86d..755c70a0b3e 100644 --- a/src/opnsense/www/index.php +++ b/src/opnsense/www/index.php @@ -58,27 +58,23 @@ function view_html_safe($text) } try { - /** - * Read the configuration - */ $config = include __DIR__ . "/../mvc/app/config/config.php"; - - /** - * Read auto-loader - */ include __DIR__ . "/../mvc/app/config/loader.php"; - /** - * Read services - */ - include __DIR__ . "/../mvc/app/config/services.php"; - - /** - * Handle the request - */ - $application = new \Phalcon\Mvc\Application($di); + $router = new OPNsense\Mvc\Router('/ui/'); + try { + $response = $router->routeRequest($_SERVER['REQUEST_URI'], [ + 'controller' => 'indexController', + 'action' => 'indexAction' + ]); + } catch (\OPNsense\Mvc\Exceptions\DispatchException) { + // unroutable (page not found), present page not found controller + $response = $router->routeRequest('/ui/core/index/index'); + } - echo $application->handle($_SERVER['REQUEST_URI'])->getContent(); + if (!$response->isSent()) { + $response->send(); + } } catch (\Error | \Exception $e) { error_log($e);