diff --git a/cli.php b/cli.php index a8dae81..637375b 100644 --- a/cli.php +++ b/cli.php @@ -188,7 +188,7 @@ function handleDatabase(array $argv) { $command = array_merge([$command_bin], $command_args); if ($config->getProperty("isDocker", false)) { - $command = array_merge(["docker", "exec", "-it", "db"], $command); + $command = array_merge(["docker", "exec", "-it", $config->getHost()], $command); } $process = proc_open($command, $descriptorSpec, $pipes, null, $env); diff --git a/core/Api/Search.class.php b/core/Api/Search.class.php new file mode 100644 index 0000000..b4ecd2b --- /dev/null +++ b/core/Api/Search.class.php @@ -0,0 +1,41 @@ + new StringType("text", 32) + ]); + } + + protected function _execute(): bool { + + $query = new SearchQuery(trim($this->getParam("text"))); + if (strlen($query->getQuery()) < 3) { + return $this->createError("You have to type at least 3 characters to search for"); + } + + $router = $this->context->router; + if ($router === null) { + return $this->createError("There is currently no router configured. Error during installation?"); + } + + $this->result["results"] = []; + foreach ($router->getRoutes(false) as $route) { + if(in_array(Searchable::class, array_keys((new \ReflectionClass($route))->getTraits()))) { + foreach ($route->doSearch($this->context, $query) as $searchResult) { + $this->result["results"][] = $searchResult->jsonSerialize(); + } + } + } + + return true; + } +} \ No newline at end of file diff --git a/core/Configuration/CreateDatabase.class.php b/core/Configuration/CreateDatabase.class.php index f6fdd21..f59c3fb 100644 --- a/core/Configuration/CreateDatabase.class.php +++ b/core/Configuration/CreateDatabase.class.php @@ -198,7 +198,7 @@ class CreateDatabase extends DatabaseScript { } } - private static function loadEntities(&$queries, $sql) { + public static function loadEntities(&$queries, $sql) { $entityDirectory = './core/Objects/DatabaseEntity/'; if (file_exists($entityDirectory) && is_dir($entityDirectory)) { $scan_arr = scandir($entityDirectory); diff --git a/core/Documents/Account.class.php b/core/Documents/Account.class.php index 0960911..1d4d9fe 100644 --- a/core/Documents/Account.class.php +++ b/core/Documents/Account.class.php @@ -10,6 +10,8 @@ use Objects\Router\Router; class Account extends TemplateDocument { public function __construct(Router $router, string $templateName) { parent::__construct($router, $templateName); + $this->title = "Account"; + $this->searchable = false; $this->enableCSP(); } diff --git a/core/Documents/Admin.class.php b/core/Documents/Admin.class.php index 3839cfe..1a6ba0f 100644 --- a/core/Documents/Admin.class.php +++ b/core/Documents/Admin.class.php @@ -10,6 +10,8 @@ class Admin extends TemplateDocument { $user = $router->getContext()->getUser(); $template = $user ? "admin.twig" : "redirect.twig"; $params = $user ? [] : ["url" => "/login"]; + $this->title = "Administration"; + $this->searchable = false; parent::__construct($router, $template, $params); $this->enableCSP(); } diff --git a/core/Documents/Info.class.php b/core/Documents/Info.class.php index d775b6d..ea731fd 100644 --- a/core/Documents/Info.class.php +++ b/core/Documents/Info.class.php @@ -10,6 +10,7 @@ use Objects\Router\Router; class Info extends HtmlDocument { public function __construct(Router $router) { parent::__construct($router, EmptyHead::class, InfoBody::class); + $this->searchable = false; } } diff --git a/core/Elements/Document.class.php b/core/Elements/Document.class.php index 52099d7..1a0f95a 100644 --- a/core/Elements/Document.class.php +++ b/core/Elements/Document.class.php @@ -6,8 +6,13 @@ use Configuration\Settings; use Driver\Logger\Logger; use Driver\SQL\SQL; use Objects\Context; +use Objects\lang\LanguageModule; +use Objects\Router\DocumentRoute; use Objects\Router\Router; use Objects\DatabaseEntity\User; +use Objects\Search\Searchable; +use Objects\Search\SearchQuery; +use Objects\Search\SearchResult; abstract class Document { @@ -18,6 +23,7 @@ abstract class Document { private ?string $cspNonce; private array $cspWhitelist; private string $domain; + protected bool $searchable; public function __construct(Router $router) { $this->router = $router; @@ -27,6 +33,13 @@ abstract class Document { $this->cspWhitelist = []; $this->domain = $this->getSettings()->getBaseUrl(); $this->logger = new Logger("Document", $this->getSQL()); + $this->searchable = false; + } + + public abstract function getTitle(): string; + + public function isSearchable(): bool { + return $this->searchable; } public function getLogger(): Logger { @@ -66,30 +79,29 @@ abstract class Document { return $this->router; } - protected function addCSPWhitelist(string $path) { - $this->cspWhitelist[] = $this->domain . $path; + public function addCSPWhitelist(string $path) { + $urlParts = parse_url($path); + if (!$urlParts || !isset($urlParts["host"])) { + $this->cspWhitelist[] = $this->domain . $path; + } else { + $this->cspWhitelist[] = $path; + } } - public function getCode(array $params = []): string { - if ($this->databaseRequired) { - $sql = $this->getSQL(); - if (is_null($sql)) { - die("Database is not configured yet."); - } else if (!$sql->isConnected()) { - die("Database is not connected: " . $sql->getLastError()); - } - } + public function loadLanguageModule(LanguageModule|string $module) { + $language = $this->getContext()->getLanguage(); + $language->loadModule($module); + } + public function sendHeaders() { if ($this->cspEnabled) { - $cspWhiteList = implode(" ", $this->cspWhitelist); - $csp = [ - "default-src 'self'", + "default-src $cspWhiteList 'self'", "object-src 'none'", "base-uri 'self'", "style-src 'self' 'unsafe-inline'", - "img-src 'self' data:", + "img-src 'self' 'unsafe-inline' data: https:;", "script-src $cspWhiteList 'nonce-$this->cspNonce'" ]; if ($this->getSettings()->isRecaptchaEnabled()) { @@ -99,7 +111,42 @@ abstract class Document { $compiledCSP = implode("; ", $csp); header("Content-Security-Policy: $compiledCSP;"); } + } - return ""; + public abstract function getCode(array $params = []); + + public function load(array $params = []): string { + + if ($this->databaseRequired) { + $sql = $this->getSQL(); + if (is_null($sql)) { + return "Database is not configured yet."; + } else if (!$sql->isConnected()) { + return "Database is not connected: " . $sql->getLastError(); + } + } + + + $code = $this->getCode($params); + $this->sendHeaders(); + return $code; + } + + public function doSearch(SearchQuery $query, DocumentRoute $route): array { + $code = $this->getCode(); + $results = Searchable::searchHtml($code, $query); + return array_map(function ($res) use ($route) { + return new SearchResult($route->getUrl(), $this->getTitle(), $res["text"]); + }, $results); + } + + public function createScript($type, $src, $content = ""): Script { + $script = new Script($type, $src, $content); + + if ($this->isCSPEnabled()) { + $script->setNonce($this->getCSPNonce()); + } + + return $script; } } \ No newline at end of file diff --git a/core/Elements/HtmlDocument.class.php b/core/Elements/HtmlDocument.class.php index 035f51c..cec2ce6 100644 --- a/core/Elements/HtmlDocument.class.php +++ b/core/Elements/HtmlDocument.class.php @@ -6,8 +6,8 @@ use Objects\Router\Router; class HtmlDocument extends Document { - protected Head $head; - protected Body $body; + protected ?Head $head; + protected ?Body $body; private ?string $activeView; public function __construct(Router $router, $headClass, $bodyClass, ?string $view = NULL) { @@ -17,8 +17,8 @@ class HtmlDocument extends Document { $this->activeView = $view; } - public function getHead(): Head { return $this->head; } - public function getBody(): Body { return $this->body; } + public function getHead(): ?Head { return $this->head; } + public function getBody(): ?Body { return $this->body; } public function getView() : ?View { @@ -35,24 +35,11 @@ class HtmlDocument extends Document { return new $view($this); } - public function createScript($type, $src, $content = ""): Script { - $script = new Script($type, $src, $content); - - if ($this->isCSPEnabled()) { - $script->setNonce($this->getCSPNonce()); - } - - return $script; - } - public function getRequestedView(): string { return $this->activeView; } function getCode(array $params = []): string { - - parent::getCode(); - // generate body first, so we can modify head $body = $this->body->getCode(); @@ -74,4 +61,11 @@ class HtmlDocument extends Document { } + public function getTitle(): string { + if ($this->head !== null) { + return $this->head->getTitle(); + } + + return "Untitled Document"; + } } \ No newline at end of file diff --git a/core/Elements/TemplateDocument.class.php b/core/Elements/TemplateDocument.class.php index b9756e4..77004a9 100644 --- a/core/Elements/TemplateDocument.class.php +++ b/core/Elements/TemplateDocument.class.php @@ -2,7 +2,12 @@ namespace Elements; +use Objects\CustomTwigFunctions; +use Objects\Router\DocumentRoute; use Objects\Router\Router; +use Objects\Search\Searchable; +use Objects\Search\SearchQuery; +use Objects\Search\SearchResult; use Twig\Environment; use Twig\Error\LoaderError; use Twig\Error\RuntimeError; @@ -11,6 +16,8 @@ use Twig\Loader\FilesystemLoader; class TemplateDocument extends Document { + const TEMPLATE_PATH = WEBROOT . '/core/Templates'; + private string $templateName; protected array $parameters; private Environment $twigEnvironment; @@ -19,14 +26,19 @@ class TemplateDocument extends Document { public function __construct(Router $router, string $templateName, array $params = []) { parent::__construct($router); - $this->title = ""; + $this->title = "Untitled Document"; $this->templateName = $templateName; $this->parameters = $params; - $this->twigLoader = new FilesystemLoader(WEBROOT . '/core/Templates'); + $this->twigLoader = new FilesystemLoader(self::TEMPLATE_PATH); $this->twigEnvironment = new Environment($this->twigLoader, [ 'cache' => WEBROOT . '/core/Cache/Templates/', 'auto_reload' => true ]); + $this->twigEnvironment->addExtension(new CustomTwigFunctions()); + } + + public function getTitle(): string { + return $this->title; } protected function getTemplateName(): string { @@ -37,8 +49,11 @@ class TemplateDocument extends Document { } + protected function setTemplate(string $file) { + $this->templateName = $file; + } + public function getCode(array $params = []): string { - parent::getCode($params); $this->loadParameters(); return $this->renderTemplate($this->templateName, $this->parameters); } @@ -48,33 +63,62 @@ class TemplateDocument extends Document { $context = $this->getContext(); $session = $context->getSession(); - $params["user"] = [ - "lang" => $context->getLanguage()->getShortCode(), - "loggedIn" => $session !== null, - "session" => ($session ? [ - "csrfToken" => $session->getCsrfToken() - ] : null) - ]; - $settings = $this->getSettings(); - $params["site"] = [ - "name" => $settings->getSiteName(), - "baseUrl" => $settings->getBaseUrl(), - "registrationEnabled" => $settings->isRegistrationAllowed(), - "title" => $this->title, - "recaptcha" => [ - "key" => $settings->isRecaptchaEnabled() ? $settings->getRecaptchaSiteKey() : null, - "enabled" => $settings->isRecaptchaEnabled(), - ], - "csp" => [ - "nonce" => $this->getCSPNonce(), - "enabled" => $this->isCSPEnabled() - ] - ]; + $language = $context->getLanguage(); + $urlParts = parse_url($this->getRouter()->getRequestedUri()); + + $params = array_replace_recursive([ + "user" => [ + "lang" => $language->getShortCode(), + "loggedIn" => $session !== null, + "session" => ($session ? [ + "csrfToken" => $session->getCsrfToken() + ] : null) + ], + "site" => [ + "name" => $settings->getSiteName(), + "url" => [ + "base" => $settings->getBaseUrl(), + "path" => $urlParts["path"], + "query" => $urlParts["query"] ?? "", + "fragment" => $urlParts["fragment"] ?? "" + ], + "lastModified" => date(L('Y-m-d H:i:s'), @filemtime(implode(DIRECTORY_SEPARATOR, [self::TEMPLATE_PATH, $name]))), + "registrationEnabled" => $settings->isRegistrationAllowed(), + "title" => $this->title, + "recaptcha" => [ + "key" => $settings->isRecaptchaEnabled() ? $settings->getRecaptchaSiteKey() : null, + "enabled" => $settings->isRecaptchaEnabled(), + ], + "csp" => [ + "nonce" => $this->getCSPNonce(), + "enabled" => $this->isCSPEnabled() + ] + ] + ], $params); return $this->twigEnvironment->render($name, $params); } catch (LoaderError | RuntimeError | SyntaxError $e) { return "Error rendering twig template: " . htmlspecialchars($e->getMessage()) . ""; } } + + protected function loadView(string $class): array { + $view = new $class($this); + $view->loadParameters($this->parameters); + if ($view->getTitle()) { + $this->title = $view->getTitle(); + } + return $this->parameters; + } + + public function doSearch(SearchQuery $query, DocumentRoute $route): array { + $this->loadParameters(); + $viewParams = $this->parameters["view"] ?? []; + $siteTitle = $this->parameters["site"]["title"] ?? $this->title; + $results = Searchable::searchArray($viewParams, $query); + return array_map(function ($res) use ($siteTitle, $route) { + return new SearchResult($route->getUrl(), $siteTitle, $res["text"]); + }, $results); + } } \ No newline at end of file diff --git a/core/Elements/TemplateView.class.php b/core/Elements/TemplateView.class.php new file mode 100644 index 0000000..fc8adc4 --- /dev/null +++ b/core/Elements/TemplateView.class.php @@ -0,0 +1,39 @@ +title = ""; + } + + protected function getParameters(): array { + return []; + } + + public function loadParameters(array &$parameters) { + + $siteParameters = [ + "title" => $this->title, + "description" => $this->description, + "keywords" => $this->keywords + ]; + + foreach ($siteParameters as $key => $value) { + if ($value) { + $parameters["site"][$key] = $value; + } + } + + $parameters["view"] = $this->getParameters(); + } + + public function getCode(): string { + return $this->getDocument()->getCode(); + } +} \ No newline at end of file diff --git a/core/Elements/View.class.php b/core/Elements/View.class.php index 42f8eaa..e8727ad 100644 --- a/core/Elements/View.class.php +++ b/core/Elements/View.class.php @@ -6,13 +6,11 @@ abstract class View extends StaticView { private Document $document; private bool $loadView; - protected bool $searchable; protected string $title; protected array $langModules; public function __construct(Document $document, bool $loadView = true) { $this->document = $document; - $this->searchable = false; $this->title = "Untitled View"; $this->langModules = array(); $this->loadView = $loadView; @@ -20,7 +18,6 @@ abstract class View extends StaticView { public function getTitle(): string { return $this->title; } public function getDocument(): Document { return $this->document; } - public function isSearchable(): bool { return $this->searchable; } public function getSiteName(): string { return $this->getDocument()->getSettings()->getSiteName(); diff --git a/core/External/composer.json b/core/External/composer.json index fdbb8b0..6a14ac7 100644 --- a/core/External/composer.json +++ b/core/External/composer.json @@ -6,7 +6,8 @@ "christian-riesen/base32": "^1.6", "spomky-labs/cbor-php": "2.1.0", "web-auth/cose-lib": "3.3.12", - "firebase/php-jwt": "^6.2" + "firebase/php-jwt": "^6.2", + "html2text/html2text": "^4.3" }, "require-dev": { "phpunit/phpunit": "^9.5" diff --git a/core/Objects/Context.class.php b/core/Objects/Context.class.php index d81320b..68a25cc 100644 --- a/core/Objects/Context.class.php +++ b/core/Objects/Context.class.php @@ -12,6 +12,7 @@ use Firebase\JWT\JWT; use Objects\DatabaseEntity\Language; use Objects\DatabaseEntity\Session; use Objects\DatabaseEntity\User; +use Objects\Router\Router; class Context { @@ -20,12 +21,14 @@ class Context { private ?User $user; private Configuration $configuration; private Language $language; + public ?Router $router; public function __construct() { $this->sql = null; $this->session = null; $this->user = null; + $this->router = null; $this->configuration = new Configuration(); $this->setLanguage(Language::DEFAULT_LANGUAGE()); diff --git a/core/Objects/CustomTwigFunctions.class.php b/core/Objects/CustomTwigFunctions.class.php new file mode 100644 index 0000000..5399993 --- /dev/null +++ b/core/Objects/CustomTwigFunctions.class.php @@ -0,0 +1,18 @@ +insert($this); + if ($res === false) { + return false; + } else if ($this->id === null) { + $this->id = $res; + } + + return true; + } + public function delete(SQL $sql): bool { $handler = self::getHandler($sql); if ($this->id === null) { diff --git a/core/Objects/DatabaseEntity/DatabaseEntityHandler.php b/core/Objects/DatabaseEntity/DatabaseEntityHandler.php index 9b50063..12d2366 100644 --- a/core/Objects/DatabaseEntity/DatabaseEntityHandler.php +++ b/core/Objects/DatabaseEntity/DatabaseEntityHandler.php @@ -311,10 +311,7 @@ class DatabaseEntityHandler { return $query->execute(); } - public function insertOrUpdate(DatabaseEntity $entity, ?array $columns = null) { - $id = $entity->getId(); - $action = $id === null ? "insert" : "update"; - + private function prepareRow(DatabaseEntity $entity, string $action, ?array $columns = null) { $row = []; foreach ($this->columns as $propertyName => $column) { if ($columns && !in_array($column->getName(), $columns)) { @@ -331,7 +328,10 @@ class DatabaseEntityHandler { } else if (!$this->columns[$propertyName]->notNull()) { $value = null; } else { - if ($action !== "update") { + $defaultValue = self::getAttribute($property, DefaultValue::class); + if ($defaultValue) { + $value = $defaultValue->getValue(); + } else if ($action !== "update") { $this->logger->error("Cannot $action entity: property '$propertyName' was not initialized yet."); return false; } else { @@ -342,29 +342,62 @@ class DatabaseEntityHandler { $row[$column->getName()] = $value; } + return $row; + } + + public function update(DatabaseEntity $entity, ?array $columns = null) { + $row = $this->prepareRow($entity, "update", $columns); + if ($row === false) { + return false; + } + + $entity->preInsert($row); + $query = $this->sql->update($this->tableName) + ->where(new Compare($this->tableName . ".id", $entity->getId())); + + foreach ($row as $columnName => $value) { + $query->set($columnName, $value); + } + + return $query->execute(); + } + + public function insert(DatabaseEntity $entity) { + $row = $this->prepareRow($entity, "insert"); + if ($row === false) { + return false; + } + $entity->preInsert($row); + // insert with id? + $entityId = $entity->getId(); + if ($entityId !== null) { + $row["id"] = $entityId; + } - if ($id === null) { - $res = $this->sql->insert($this->tableName, array_keys($row)) - ->addRow(...array_values($row)) - ->returning("id") - ->execute(); + $query = $this->sql->insert($this->tableName, array_keys($row)) + ->addRow(...array_values($row)); - if ($res !== false) { - return $this->sql->getLastInsertId(); - } else { - return false; - } + // return id if its auto-generated + if ($entityId === null) { + $query->returning("id"); + } + + $res = $query->execute(); + if ($res !== false) { + return $this->sql->getLastInsertId(); } else { - $query = $this->sql->update($this->tableName) - ->where(new Compare($this->tableName . ".id", $id)); + return false; + } + } - foreach ($row as $columnName => $value) { - $query->set($columnName, $value); - } - - return $query->execute(); + public function insertOrUpdate(DatabaseEntity $entity, ?array $columns = null) { + $id = $entity->getId(); + if ($id === null) { + return $this->insert($entity); + } else { + return $this->update($entity, $columns); } } diff --git a/core/Objects/DatabaseEntity/Language.class.php b/core/Objects/DatabaseEntity/Language.class.php index 99b7b4a..de68a85 100644 --- a/core/Objects/DatabaseEntity/Language.class.php +++ b/core/Objects/DatabaseEntity/Language.class.php @@ -2,6 +2,7 @@ namespace Objects\DatabaseEntity { + use Driver\SQL\SQL; use Objects\DatabaseEntity\Attribute\MaxLength; use Objects\DatabaseEntity\Attribute\Transient; use Objects\lang\LanguageModule; @@ -14,15 +15,13 @@ namespace Objects\DatabaseEntity { #[MaxLength(5)] private string $code; #[MaxLength(32)] private string $name; - #[Transient] private array $modules; - #[Transient] protected array $entries; + #[Transient] private array $modules = []; + #[Transient] protected array $entries = []; public function __construct(int $id, string $code, string $name) { parent::__construct($id); $this->code = $code; $this->name = $name; - $this->entries = array(); - $this->modules = array(); } public function getCode(): string { @@ -42,9 +41,11 @@ namespace Objects\DatabaseEntity { $module = new $module(); } - $moduleEntries = $module->getEntries($this->code); - $this->entries = array_merge($this->entries, $moduleEntries); - $this->modules[] = $module; + if (!in_array($module, $this->modules)) { + $moduleEntries = $module->getEntries($this->code); + $this->entries = array_merge($this->entries, $moduleEntries); + $this->modules[] = $module; + } } public function translate(string $key): string { @@ -89,6 +90,10 @@ namespace Objects\DatabaseEntity { return new Language(1, "en_US", "American English"); } + + public function getEntries(): array { + return $this->entries; + } } } diff --git a/core/Objects/DatabaseEntity/Session.class.php b/core/Objects/DatabaseEntity/Session.class.php index 9bc5657..400376e 100644 --- a/core/Objects/DatabaseEntity/Session.class.php +++ b/core/Objects/DatabaseEntity/Session.class.php @@ -105,12 +105,6 @@ class Session extends DatabaseEntity { ); } - public function insert(bool $stayLoggedIn = false): bool { - $this->stayLoggedIn = $stayLoggedIn; - $this->active = true; - return $this->update(); - } - public function destroy(): bool { session_destroy(); $this->active = false; diff --git a/core/Objects/Router/AbstractRoute.class.php b/core/Objects/Router/AbstractRoute.class.php index c142d33..19c0bc1 100644 --- a/core/Objects/Router/AbstractRoute.class.php +++ b/core/Objects/Router/AbstractRoute.class.php @@ -6,6 +6,8 @@ use Api\Parameter\Parameter; abstract class AbstractRoute { + const PARAMETER_PATTERN = "/^{([^:]+)(:(.*?)(\?)?)?}$/"; + private string $pattern; private bool $exact; @@ -64,7 +66,7 @@ abstract class AbstractRoute { $params = []; for (; $patternOffset < $countPattern; $patternOffset++) { - if (!preg_match("/^{([^:]+)(:(.*?)(\?)?)?}$/", $patternParts[$patternOffset], $match)) { + if (!preg_match(self::PARAMETER_PATTERN, $patternParts[$patternOffset], $match)) { // not a parameter? check if it matches if ($urlOffset >= $countUrl || $urlParts[$urlOffset] !== $patternParts[$patternOffset]) { @@ -122,4 +124,30 @@ abstract class AbstractRoute { return $params; } + + public function getUrl(array $parameters = []): string { + $patternParts = explode("/", Router::cleanURL($this->pattern, false)); + + foreach ($patternParts as $i => $part) { + if (preg_match(self::PARAMETER_PATTERN, $part, $match)) { + $paramName = $match[1]; + $patternParts[$i] = $parameters[$paramName] ?? null; + } + } + + return "/" . implode("/", array_filter($patternParts)); + } + + public function getParameterNames(): array { + $parameterNames = []; + $patternParts = explode("/", Router::cleanURL($this->pattern, false)); + + foreach ($patternParts as $part) { + if (preg_match(self::PARAMETER_PATTERN, $part, $match)) { + $parameterNames[] = $match[1]; + } + } + + return $parameterNames; + } } \ No newline at end of file diff --git a/core/Objects/Router/ApiRoute.class.php b/core/Objects/Router/ApiRoute.class.php index 7e1b715..187e1cc 100644 --- a/core/Objects/Router/ApiRoute.class.php +++ b/core/Objects/Router/ApiRoute.class.php @@ -21,7 +21,7 @@ class ApiRoute extends AbstractRoute { if (empty($params["endpoint"])) { header("Content-Type: text/html"); $document = new \Elements\TemplateDocument($router, "swagger.twig"); - return $document->getCode(); + return $document->load(); } else if (!preg_match("/[a-zA-Z]+/", $params["endpoint"])) { http_response_code(400); $response = createError("Invalid Method"); diff --git a/core/Objects/Router/DocumentRoute.class.php b/core/Objects/Router/DocumentRoute.class.php index a24af64..0a3b730 100644 --- a/core/Objects/Router/DocumentRoute.class.php +++ b/core/Objects/Router/DocumentRoute.class.php @@ -3,10 +3,15 @@ namespace Objects\Router; use Elements\Document; +use Objects\Context; +use Objects\Search\Searchable; +use Objects\Search\SearchQuery; use ReflectionException; class DocumentRoute extends AbstractRoute { + use Searchable; + private string $className; private array $args; private ?\ReflectionClass $reflectionClass; @@ -31,7 +36,7 @@ class DocumentRoute extends AbstractRoute { } } catch (ReflectionException $exception) { $this->reflectionClass = null; - return false; + throw $exception; } $this->reflectionClass = null; @@ -55,16 +60,32 @@ class DocumentRoute extends AbstractRoute { } public function call(Router $router, array $params): string { - if (!$this->loadClass()) { - return $router->returnStatusCode(500, [ "message" => "Error loading class: $this->className"]); - } - try { - $args = array_merge([$router], $this->args); + if (!$this->loadClass()) { + return $router->returnStatusCode(500, [ "message" => "Error loading class: $this->className"]); + } + + $args = array_merge([$router], $this->args, $params); $document = $this->reflectionClass->newInstanceArgs($args); - return $document->getCode($params); + return $document->load($params); } catch (\ReflectionException $e) { return $router->returnStatusCode(500, [ "message" => "Error loading class $this->className: " . $e->getMessage()]); } } + + public function doSearch(Context $context, SearchQuery $query): array { + try { + if ($this->loadClass()) { + $args = array_merge([$context->router], $this->args); + $document = $this->reflectionClass->newInstanceArgs($args); + if ($document->isSearchable()) { + return $document->doSearch($query, $this); + } + } + + return []; + } catch (\ReflectionException) { + return []; + } + } } \ No newline at end of file diff --git a/core/Objects/Router/Router.class.php b/core/Objects/Router/Router.class.php index 5bf3c25..facb057 100644 --- a/core/Objects/Router/Router.class.php +++ b/core/Objects/Router/Router.class.php @@ -9,6 +9,8 @@ class Router { private Context $context; private Logger $logger; + private ?AbstractRoute $activeRoute; + private ?string $requestedUri; protected array $routes; protected array $statusCodeRoutes; @@ -16,6 +18,9 @@ class Router { $this->context = $context; $this->routes = []; $this->statusCodeRoutes = []; + $this->activeRoute = null; + $this->requestedUri = null; + $this->context->router = $this; $sql = $context->getSQL(); if ($sql) { @@ -26,14 +31,24 @@ class Router { } } + public function getActiveRoute(): ?AbstractRoute { + return $this->activeRoute; + } + + public function getRequestedUri(): ?string { + return $this->requestedUri; + } + public function run(string $url): string { // TODO: do we want a global try cache and return status page 500 on any error? + $this->requestedUri = $url; $url = strtok($url, "?"); foreach ($this->routes as $route) { $pathParams = $route->match($url); if ($pathParams !== false) { + $this->activeRoute = $route; return $route->call($this, $pathParams); } } @@ -54,7 +69,6 @@ class Router { if ($res) { return $req->getResult()["html"]; } else { - var_dump($req->getLastError()); $description = htmlspecialchars($params["status_description"]); return "$code - $description"; } @@ -62,11 +76,11 @@ class Router { } public function addRoute(AbstractRoute $route) { - if (preg_match("/^\d+$/", $route->getPattern())) { - $this->statusCodeRoutes[$route->getPattern()] = $route; - } else { - $this->routes[] = $route; + if (preg_match("/^\/(\d+)$/", $route->getPattern(), $re)) { + $this->statusCodeRoutes[$re[1]] = $route; } + + $this->routes[] = $route; } public function writeCache(string $file): bool { @@ -134,4 +148,15 @@ class RouterCache extends Router { // strip leading slash return preg_replace("/^\/+/", "", $url); } + + public function getRoutes(bool $includeStatusRoutes = false): array { + + if (!$includeStatusRoutes && !empty($this->statusCodeRoutes)) { + return array_filter($this->routes, function ($route) { + return !in_array($route, $this->statusCodeRoutes); + }); + } + + return $this->routes; + } } \ No newline at end of file diff --git a/core/Objects/Router/StaticFileRoute.class.php b/core/Objects/Router/StaticFileRoute.class.php index bc58eda..a6e58ba 100644 --- a/core/Objects/Router/StaticFileRoute.class.php +++ b/core/Objects/Router/StaticFileRoute.class.php @@ -2,8 +2,15 @@ namespace Objects\Router; +use Objects\Context; +use Objects\Search\Searchable; +use Objects\Search\SearchQuery; +use Objects\Search\SearchResult; + class StaticFileRoute extends AbstractRoute { + use Searchable; + private string $path; private int $code; @@ -15,7 +22,7 @@ class StaticFileRoute extends AbstractRoute { public function call(Router $router, array $params): string { http_response_code($this->code); - $this->serveStatic($this->path, $router); + $this->serveStatic($this->getAbsolutePath(), $router); return ""; } @@ -23,9 +30,11 @@ class StaticFileRoute extends AbstractRoute { return array_merge(parent::getArgs(), [$this->path, $this->code]); } - public static function serveStatic(string $path, ?Router $router = null) { + public function getAbsolutePath(): string { + return WEBROOT . DIRECTORY_SEPARATOR . $this->path; + } - $path = realpath(WEBROOT . DIRECTORY_SEPARATOR . $path); + public static function serveStatic(string $path, ?Router $router = null) { if (!startsWith($path, WEBROOT . DIRECTORY_SEPARATOR)) { http_response_code(406); echo "Access restricted, requested file outside web root: " . htmlspecialchars($path); @@ -72,4 +81,34 @@ class StaticFileRoute extends AbstractRoute { downloadFile($handle, $offset, $length); } } + + public function doSearch(Context $context, SearchQuery $query): array { + + $results = []; + $path = $this->getAbsolutePath(); + if (is_file($path) && is_readable($path)) { + $pathInfo = pathinfo($path); + $extension = $pathInfo["extension"] ?? ""; + $fileName = $pathInfo["filename"] ?? ""; + if ($context->getSettings()->isExtensionAllowed($extension)) { + $mimeType = mime_content_type($path); + if (startsWith($mimeType, "text/")) { + $document = @file_get_contents($path); + if ($document) { + if ($mimeType === "text/html") { + $results = Searchable::searchHtml($document, $query); + } else { + $results = Searchable::searchText($document, $query); + } + } + } + } + + $results = array_map(function ($res) use ($fileName) { + return new SearchResult($this->getPattern(), $fileName, $res["text"]); + }, $results); + } + + return $results; + } } \ No newline at end of file diff --git a/core/Objects/Search/SearchQuery.class.php b/core/Objects/Search/SearchQuery.class.php new file mode 100644 index 0000000..16bd172 --- /dev/null +++ b/core/Objects/Search/SearchQuery.class.php @@ -0,0 +1,19 @@ +query = $query; + $this->parts = array_unique(array_filter(explode(" ", strtolower($query)))); + } + + public function getQuery(): string { + return $this->query; + } + +} \ No newline at end of file diff --git a/core/Objects/Search/SearchResult.class.php b/core/Objects/Search/SearchResult.class.php new file mode 100644 index 0000000..84ce47f --- /dev/null +++ b/core/Objects/Search/SearchResult.class.php @@ -0,0 +1,30 @@ +url = $url; + $this->title = $title; + $this->text = $text; + } + + public function jsonSerialize(): array { + return [ + "url" => $this->url, + "title" => $this->title, + "text" => $this->text + ]; + } + + public function setUrl(string $url) { + $this->url = $url; + } +} \ No newline at end of file diff --git a/core/Objects/Search/Searchable.trait.php b/core/Objects/Search/Searchable.trait.php new file mode 100644 index 0000000..121e1b3 --- /dev/null +++ b/core/Objects/Search/Searchable.trait.php @@ -0,0 +1,91 @@ + $value) { + $results = array_merge($results, self::searchHtml($key, $query)); + if (is_array($value)) { + $results = array_merge($results, self::searchArray($value, $query)); + } else { + $results = array_merge($results, self::searchHtml(strval($value), $query)); + } + } + + return $results; + } + + public abstract function doSearch(Context $context, SearchQuery $query): array; + + public static function searchHtml(string $document, SearchQuery $query): array { + if (stringContains($document, "<")) { + $converter = new Html2Text($document); + $text = trim($converter->getText()); + } else { + $text = $document; + } + + $text = trim(preg_replace('!\s+!', ' ', $text)); + return self::searchText($text, $query); + } + + public static function searchText(string $content, SearchQuery $query): array { + $offset = 0; + $searchTerm = $query->getQuery(); + $stringLength = strlen($searchTerm); + $contentLength = strlen($content); + $lastPos = 0; + + $results = []; + do { + $pos = stripos($content, $searchTerm, $offset); + if ($pos !== false) { + if ($lastPos === 0 || $pos > $lastPos + 192 + $stringLength) { + $extract = self::viewExtract($content, $pos, $searchTerm); + $results[] = array( + "text" => $extract, + "pos" => $pos, + "lastPos" => $lastPos + ); + $lastPos = $pos; + } + + $offset = $pos + $stringLength; + } + } while ($pos !== false && $offset < $contentLength); + + return $results; + } + + private static function viewExtract(string $content, int $pos, $string): array|string|null { + $length = strlen($string); + $start = max(0, $pos - 32); + $end = min(strlen($content) - 1, $pos + $length + 192); + + if ($start > 0) { + if (!ctype_space($content[$start - 1]) && + !ctype_space($content[$start])) { + $start = $start + strpos(substr($content, $start, $end), ' '); + } + } + + if ($end < strlen($content) - 1) { + if (!ctype_space($content[$end + 1]) && + !ctype_space($content[$end])) { + $end = $start + strrpos(substr($content, $start, $end - $start), ' '); + } + } + + $extract = trim(substr($content, $start, $end - $start + 1)); + if ($start > 0) $extract = ".. " . $extract; + if ($end < strlen($content) - 1) $extract .= " .."; + return preg_replace("/" . preg_quote($string) . "(?=[^>]*(<|$))/i", "\$0", $extract); + } +} \ No newline at end of file diff --git a/core/core.php b/core/core.php index 23baeeb..d180af3 100644 --- a/core/core.php +++ b/core/core.php @@ -10,7 +10,7 @@ if (is_file($autoLoad)) { require_once $autoLoad; } -define("WEBBASE_VERSION", "2.0.0-alpha"); +define("WEBBASE_VERSION", "2.1.0"); spl_autoload_extensions(".php"); spl_autoload_register(function ($class) { diff --git a/index.php b/index.php index 2043ca7..c6fd99b 100644 --- a/index.php +++ b/index.php @@ -35,7 +35,7 @@ if ($installation) { header("Location: /"); } else { $document = new Documents\Install(new Router($context)); - $response = $document->getCode(); + $response = $document->load(); } } else { diff --git a/js/script.js b/js/script.js index 02f00c9..ab8da20 100644 --- a/js/script.js +++ b/js/script.js @@ -41,7 +41,7 @@ let Core = function () { }; this.getParameters = function () { - return this.aParameters; + return this.parameters; }; this.setTitle = function (title) { @@ -71,27 +71,27 @@ let Core = function () { }; this.removeParameter = function (param) { - if (typeof this.aParameters[param] !== 'undefined' && this.aParameters.hasOwnProperty(param)) { - delete this.aParameters[param]; + if (typeof this.parameters[param] !== 'undefined' && this.parameters.hasOwnProperty(param)) { + delete this.parameters[param]; } this.updateUrl(); }; this.getParameter = function (param) { - if (typeof this.aParameters[param] !== 'undefined' && this.aParameters.hasOwnProperty(param)) - return this.aParameters[param]; + if (typeof this.parameters[param] !== 'undefined' && this.parameters.hasOwnProperty(param)) + return this.parameters[param]; else return null; }; this.setParameter = function (param, newvalue) { newvalue = typeof newvalue !== 'undefined' ? newvalue : ''; - this.aParameters[param] = newvalue; + this.parameters[param] = newvalue; this.updateUrl(); }; this.parseParameters = function () { - this.aParameters = []; + this.parameters = []; if (this.url.indexOf('?') === -1) return; @@ -103,30 +103,32 @@ let Core = function () { if (index !== -1) { var key = param.substr(0, index); var val = param.substr(index + 1); - this.aParameters[key] = val; + this.parameters[key] = val; } else - this.aParameters[param] = ''; + this.parameters[param] = ''; } }; this.updateUrl = function () { this.clearUrl(); let i = 0; - for (var parameter in this.aParameters) { + for (var parameter in this.parameters) { this.url += (i === 0 ? "?" : "&") + parameter; - if (this.aParameters.hasOwnProperty(parameter) && this.aParameters[parameter].toString().length > 0) { - this.url += "=" + this.aParameters[parameter]; + if (this.parameters.hasOwnProperty(parameter) && this.parameters[parameter].toString().length > 0) { + this.url += "=" + this.parameters[parameter]; } i++; } }; this.clearParameters = function () { - this.aParameters = []; + this.parameters = []; this.updateUrl(); }; this.clearUrl = function () { + if (this.url.indexOf('#') !== -1) + this.url = this.url.substring(0, this.url.indexOf('#')); if (this.url.indexOf('?') !== -1) this.url = this.url.substring(0, this.url.indexOf('?')); };