Namespace and ClassPath rewrites

This commit is contained in:
2022-11-18 18:06:46 +01:00
parent c277aababc
commit 951ff14c5f
217 changed files with 1017 additions and 936 deletions

View File

@@ -0,0 +1,9 @@
<?php
namespace Core\Elements;
abstract class Body extends View {
public function __construct($document) {
parent::__construct($document);
}
}

View File

@@ -0,0 +1,152 @@
<?php
namespace Core\Elements;
use Core\Configuration\Settings;
use Core\Driver\Logger\Logger;
use Core\Driver\SQL\SQL;
use Core\Objects\Context;
use Core\Objects\lang\LanguageModule;
use Core\Objects\Router\DocumentRoute;
use Core\Objects\Router\Router;
use Core\Objects\DatabaseEntity\User;
use Core\Objects\Search\Searchable;
use Core\Objects\Search\SearchQuery;
use Core\Objects\Search\SearchResult;
abstract class Document {
protected Router $router;
private Logger $logger;
protected bool $databaseRequired;
private bool $cspEnabled;
private ?string $cspNonce;
private array $cspWhitelist;
private string $domain;
protected bool $searchable;
public function __construct(Router $router) {
$this->router = $router;
$this->cspEnabled = false;
$this->cspNonce = null;
$this->databaseRequired = true;
$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 {
return $this->logger;
}
public function getUser(): ?User {
return $this->getContext()->getUser();
}
public function getContext(): Context {
return $this->router->getContext();
}
public function getSQL(): ?SQL {
return $this->getContext()->getSQL();
}
public function getSettings(): Settings {
return $this->getContext()->getSettings();
}
public function getCSPNonce(): ?string {
return $this->cspNonce;
}
public function isCSPEnabled(): bool {
return $this->cspEnabled;
}
public function enableCSP() {
$this->cspEnabled = true;
$this->cspNonce = generateRandomString(16, "base62");
}
public function getRouter(): Router {
return $this->router;
}
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 loadLanguageModule(LanguageModule|string $module) {
$language = $this->getContext()->getLanguage();
$language->loadModule($module);
}
public function sendHeaders() {
if ($this->cspEnabled) {
$cspWhiteList = implode(" ", $this->cspWhitelist);
$csp = [
"default-src $cspWhiteList 'self'",
"object-src 'none'",
"base-uri 'self'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' 'unsafe-inline' data: https:;",
"script-src $cspWhiteList 'nonce-$this->cspNonce'"
];
if ($this->getSettings()->isRecaptchaEnabled()) {
$csp[] = "frame-src https://www.google.com/ 'self'";
}
$compiledCSP = implode("; ", $csp);
header("Content-Security-Policy: $compiledCSP;");
}
}
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;
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace Core\Elements;
class EmptyHead extends Head {
public function __construct($document) {
parent::__construct($document);
}
protected function initSources() {
}
protected function initMetas(): array {
return array(
);
}
protected function initRawFields(): array {
return array();
}
protected function initTitle(): string {
return "";
}
}

View File

@@ -0,0 +1,110 @@
<?php
namespace Core\Elements;
abstract class Head extends View {
protected array $sources;
protected string $title;
protected array $metas;
protected array $rawFields;
protected array $keywords;
protected string $description;
protected string $baseUrl;
function __construct($document) {
parent::__construct($document);
$this->sources = array();
$this->searchable = false;
$this->metas = $this->initMetas();
$this->rawFields = $this->initRawFields();
$this->title = $this->initTitle();
$this->initSources();
$this->init();
}
protected abstract function initSources();
protected abstract function initMetas(): array;
protected abstract function initRawFields(): array;
protected abstract function initTitle(): string;
protected function init() {
$this->keywords = array();
$this->description = "";
$this->baseUrl = "";
}
public function setBaseUrl($baseUrl) { $this->baseUrl = $baseUrl; }
public function setDescription($description) { $this->description = $description; }
public function setKeywords($keywords) { $this->keywords = $keywords; }
public function setTitle($title) { $this->title = $title; }
public function getSources(): array { return $this->sources; }
public function addScript($type, $url, $js = '') { $this->sources[] = new Script($type, $url, $js); }
public function addRawField($rawField) { $this->rawFields[] = $rawField; }
public function addMeta($aMeta) { $this->metas[] = $aMeta; }
public function addLink($rel, $href, $type = "") { $this->sources[] = new Link($rel, $href, $type); }
public function addKeywords($keywords) { $this->keywords = array_merge($this->keywords, $keywords); }
public function getTitle(): string { return $this->title; }
public function addCSS($href, $type = Link::MIME_TEXT_CSS) { $this->sources[] = new Link(Link::STYLESHEET, $href, $type); }
public function addStyle($style) { $this->sources[] = new Style($style); }
public function addJS($url) { $this->sources[] = new Script(Script::MIME_TEXT_JAVASCRIPT, $url, ""); }
public function addJSCode($code) { $this->sources[] = new Script(Script::MIME_TEXT_JAVASCRIPT, "", $code); }
public function loadFontawesome() {
$this->addCSS(Link::FONTAWESOME);
}
public function loadGoogleRecaptcha($siteKey) {
$this->addJS("https://www.google.com/recaptcha/api.js?render=$siteKey");
}
public function loadJQuery() {
$this->addJS(Script::JQUERY);
}
public function loadBootstrap() {
$this->addCSS(Link::BOOTSTRAP);
$this->addJS(Script::BOOTSTRAP);
}
public function getCode(): string {
$content = [];
// meta tags
foreach($this->metas as $meta) {
$content[] = html_tag_short("meta", $meta);
}
// description
if(!empty($this->description)) {
$content[] = html_tag_short("meta", ["name" => "description", "content" => $this->description]);
}
// keywords
if(!empty($this->keywords)) {
$keywords = implode(", ", $this->keywords);
$content[] = html_tag_short("meta", ["name" => "keywords", "content" => $keywords]);
}
// base tag
if(!empty($this->baseUrl)) {
$content[] = html_tag_short("base", ["href" => $this->baseUrl]);
}
// title
$content[] = html_tag("title", [], $this->title);
// src tags
foreach($this->sources as $src) {
$content[] = $src->getCode();
}
//
foreach ($this->rawFields as $raw) {
$content[] = $raw;
}
return html_tag("head", [], $content, false);
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace Core\Elements;
use Core\Objects\Router\Router;
class HtmlDocument extends Document {
protected ?Head $head;
protected ?Body $body;
private ?string $activeView;
public function __construct(Router $router, $headClass, $bodyClass, ?string $view = NULL) {
parent::__construct($router);
$this->head = $headClass ? new $headClass($this) : null;
$this->body = $bodyClass ? new $bodyClass($this) : null;
$this->activeView = $view;
}
public function getHead(): ?Head { return $this->head; }
public function getBody(): ?Body { return $this->body; }
public function getView() : ?View {
if ($this->activeView === null) {
return null;
}
$view = parseClass($this->activeView);
$file = getClassPath($view);
if (!file_exists($file) || !is_subclass_of($view, View::class)) {
return null;
}
return new $view($this);
}
public function getRequestedView(): string {
return $this->activeView;
}
function getCode(array $params = []): string {
// generate body first, so we can modify head
$body = $this->body->getCode();
if ($this->isCSPEnabled()) {
foreach ($this->head->getSources() as $element) {
if ($element instanceof Script || $element instanceof Link) {
$element->setNonce($this->getCSPNonce());
}
}
}
$head = $this->head->getCode();
$lang = $this->getContext()->getLanguage();
$code = "<!DOCTYPE html>";
$code .= html_tag("html", ["lang" => $lang->getShortCode()], $head . $body, false);
return $code;
}
public function getTitle(): string {
if ($this->head !== null) {
return $this->head->getTitle();
}
return "Untitled Document";
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace Core\Elements;
class Link extends StaticView {
const STYLESHEET = "stylesheet";
const MIME_TEXT_CSS = "text/css";
const FONTAWESOME = "/css/fontawesome.min.css";
const BOOTSTRAP = "/css/bootstrap.min.css";
const CORE = "/css/style.css";
const ACCOUNT = "/css/account.css";
private string $type;
private string $rel;
private string $href;
private ?string $nonce;
function __construct($rel, $href, $type = "") {
$this->href = $href;
$this->type = $type;
$this->rel = $rel;
$this->nonce = null;
}
function getCode(): string {
$attributes = ["rel" => $this->rel, "href" => $this->href];
if (!empty($this->type)) {
$attributes["type"] = $this->type;
}
if (!empty($this->nonce)) {
$attributes["nonce"] = $this->nonce;
}
return html_tag_short("link", $attributes);
}
public function setNonce(string $nonce) {
$this->nonce = $nonce;
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace Core\Elements;
class Script extends StaticView {
const MIME_TEXT_JAVASCRIPT = "text/javascript";
const CORE = "/js/script.js";
const JQUERY = "/js/jquery.min.js";
const INSTALL = "/js/install.js";
const BOOTSTRAP = "/js/bootstrap.bundle.min.js";
const ACCOUNT = "/js/account.js";
const FONTAWESOME = "/js/fontawesome-all.min.js";
private string $type;
private string $content;
private string $src;
private ?string $nonce;
function __construct($type, $src, $content = "") {
$this->src = $src;
$this->type = $type;
$this->content = $content;
$this->nonce = null;
}
function getCode(): string {
$attributes = ["type" => $this->type];
if (!empty($this->src)) {
$attributes["src"] = $this->src;
}
if (!empty($this->nonce)) {
$attributes["nonce"] = $this->nonce;
}
// TODO: do we need to escape the content here?
return html_tag("script", $attributes, $this->content, false);
}
public function setNonce(string $nonce) {
$this->nonce = $nonce;
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace Core\Elements;
abstract class SimpleBody extends Body {
public function __construct($document) {
parent::__construct($document);
}
public function getCode(): string {
$content = $this->getContent();
return html_tag("body", [], $content, false);
}
protected abstract function getContent(): string;
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Core\Elements;
abstract class StaticView {
public abstract function getCode();
public function __toString() {
return $this->getCode();
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace Core\Elements;
class Style extends StaticView {
private string $style;
function __construct($style) {
$this->style = $style;
}
function getCode(): string {
// TODO: do we need to escape the content here?
return html_tag("style", [], $this->style, false);
}
}

View File

@@ -0,0 +1,124 @@
<?php
namespace Core\Elements;
use Core\Objects\CustomTwigFunctions;
use Core\Objects\Router\DocumentRoute;
use Core\Objects\Router\Router;
use Core\Objects\Search\Searchable;
use Core\Objects\Search\SearchQuery;
use Core\Objects\Search\SearchResult;
use Twig\Environment;
use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;
use Twig\Loader\FilesystemLoader;
class TemplateDocument extends Document {
const TEMPLATE_PATH = WEBROOT . '/Core/Templates';
private string $templateName;
protected array $parameters;
private Environment $twigEnvironment;
private FilesystemLoader $twigLoader;
protected string $title;
public function __construct(Router $router, string $templateName, array $params = []) {
parent::__construct($router);
$this->title = "Untitled Document";
$this->templateName = $templateName;
$this->parameters = $params;
$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 {
return $this->templateName;
}
protected function loadParameters() {
}
protected function setTemplate(string $file) {
$this->templateName = $file;
}
public function getCode(array $params = []): string {
$this->loadParameters();
return $this->renderTemplate($this->templateName, $this->parameters);
}
public function renderTemplate(string $name, array $params = []): string {
try {
$context = $this->getContext();
$session = $context->getSession();
$settings = $this->getSettings();
$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 "<b>Error rendering twig template: " . htmlspecialchars($e->getMessage()) . "</b>";
}
}
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);
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace Core\Elements;
abstract class TemplateView extends View {
protected array $keywords = [];
protected string $description = "";
public function __construct(TemplateDocument $document) {
parent::__construct($document);
$this->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();
}
}

View File

@@ -0,0 +1,146 @@
<?php
namespace Core\Elements;
abstract class View extends StaticView {
private Document $document;
private bool $loadView;
protected string $title;
protected array $langModules;
public function __construct(Document $document, bool $loadView = true) {
$this->document = $document;
$this->title = "Untitled View";
$this->langModules = array();
$this->loadView = $loadView;
}
public function getTitle(): string { return $this->title; }
public function getDocument(): Document { return $this->document; }
public function getSiteName(): string {
return $this->getDocument()->getSettings()->getSiteName();
}
protected function load(string $viewClass) : string {
try {
$reflectionClass = new \ReflectionClass($viewClass);
if ($reflectionClass->isSubclassOf(View::class) && $reflectionClass->isInstantiable()) {
$view = $reflectionClass->newInstanceArgs(array($this->getDocument()));
$view->loadView();
return $view;
}
} catch(\ReflectionException $e) {
$this->document->getLogger()->error("Error loading view: '$viewClass': " . $e->getMessage());
}
return "";
}
private function loadLanguageModules() {
$lang = $this->document->getContext()->getLanguage();
foreach ($this->langModules as $langModule) {
$lang->loadModule($langModule);
}
}
// Virtual Methods
public function loadView() { }
public function getCode(): string {
// Load translations
$this->loadLanguageModules();
// Load metadata + head (title, scripts, includes, ...)
if($this->loadView) {
$this->loadView();
}
return '';
}
// UI Functions
private function createList(array $items, string $tag, array $classes = []): string {
$attributes = [];
if (!empty($classes)) {
$attributes["class"] = implode(" ", $classes);
}
$content = array_map(function ($item) { return html_tag("li", [], $item, false); }, $items);
return html_tag_ex($tag, $attributes, $content, false);
}
public function createOrderedList(array $items=[], array $classes=[]): string {
return $this->createList($items, "ol", $classes);
}
public function createUnorderedList(array $items=[], array $classes=[]): string {
return $this->createList($items, "ul", $classes);
}
protected function createLink(string $link, $title=null, array $classes=[], bool $escapeTitle=true): string {
$attrs = ["href" => $link];
if (!empty($classes)) {
$attrs["class"] = implode(" ", $classes);
}
return html_tag("a", $attrs, $title ?? $link, $escapeTitle);
}
protected function createExternalLink(string $link, $title=null, bool $escapeTitle=true): string {
$attrs = ["href" => $link, "target" => "_blank", "rel" => "noopener noreferrer", "class" => "external"];
return html_tag("a", $attrs, $title ?? $link, $escapeTitle);
}
protected function createIcon($icon, $type="fas", $classes = []): string {
$classes = array_merge($classes, [$type, "fa-$icon"]);
if ($icon === "spinner" || $icon === "circle-notch") {
$classes[] = "fa-spin";
}
return html_tag("i", ["class" => implode(" ", $classes)]);
}
protected function createErrorText($text, $id="", $hidden=false): string {
return $this->createStatusText("danger", $text, $id, $hidden);
}
protected function createWarningText($text, $id="", $hidden=false): string {
return $this->createStatusText("warning", $text, $id, $hidden);
}
protected function createSuccessText($text, $id="", $hidden=false): string {
return $this->createStatusText("success", $text, $id, $hidden);
}
protected function createSecondaryText($text, $id="", $hidden=false): string {
return $this->createStatusText("secondary", $text, $id, $hidden);
}
protected function createInfoText($text, $id="", $hidden=false): string {
return $this->createStatusText("info", $text, $id, $hidden);
}
protected function createStatusText(string $type, $text, string $id="", bool $hidden=false, array $classes=[]): string {
$classes[] = "alert";
$classes[] = "alert-$type";
if ($hidden) {
$classes[] = "hidden";
}
$attributes = [
"class" => implode(" ", $classes),
"role" => "alert"
];
if (!empty($id)) {
$attributes["id"] = $id;
}
return html_tag("div", $attributes, $text, false);
}
}