diff --git a/adminPanel/src/elements/sidebar.js b/adminPanel/src/elements/sidebar.js index 6c2a2ea..cb11785 100644 --- a/adminPanel/src/elements/sidebar.js +++ b/adminPanel/src/elements/sidebar.js @@ -8,8 +8,7 @@ export default function Sidebar(props) { showDialog: props.showDialog || function() {}, api: props.api, notifications: props.notifications || [ ], - contactRequests: props.contactRequests || [ ], - filesPath: props.filesPath || null + contactRequests: props.contactRequests || [ ] }; function onLogout() { @@ -86,16 +85,6 @@ export default function Sidebar(props) { ); } - let filePath = parent.filesPath; - if (filePath) { - li.push(
  • - - -

    Files

    -
    -
  • ); - } - li.push(
  • onLogout()} className={"nav-link"}> diff --git a/adminPanel/src/index.js b/adminPanel/src/index.js index 0d62502..cf8cd9f 100644 --- a/adminPanel/src/index.js +++ b/adminPanel/src/index.js @@ -32,8 +32,7 @@ class AdminDashboard extends React.Component { loaded: false, dialog: { onClose: () => this.hideDialog() }, notifications: [ ], - contactRequests: [ ], - filesPath: null + contactRequests: [ ] }; } @@ -71,35 +70,12 @@ class AdminDashboard extends React.Component { }); } - fetchFilesPath() { - this.api.getRoutes().then((res) => { - if (!res.success) { - this.showDialog("Error fetching routes: " + res.msg, "Error fetching routes"); - } else { - for (const route of res.routes) { - if (route.target === "\\Documents\\Files") { - // prepare the path patterns, e.g. '/files(/.*)?' => '/files' - let path = route.request; - path = path.replace(/\(.*\)([?*])/g, ''); // remove optional and 0-n groups - path = path.replace(/.\*/g, ''); // remove .* - path = path.replace(/\[.*]\*/g, ''); // remove []* - path = path.replace(/(.*)\+/g, "$1"); // replace 1-n groups with one match - // todo: add some more rules, but we should have most of the cases now - this.setState({...this.state, filesPath: path }); - break; - } - } - } - }); - } - componentDidMount() { this.api.fetchUser().then(Success => { if (!Success) { document.location = "/admin"; } else { this.fetchNotifications(); - this.fetchFilesPath(); this.fetchContactRequests(); setInterval(this.onUpdate.bind(this), 60*1000); this.setState({...this.state, loaded: true}); @@ -121,7 +97,7 @@ class AdminDashboard extends React.Component { return
    - +
    diff --git a/core/Api/Request.class.php b/core/Api/Request.class.php index f3e75c2..4f94ea4 100644 --- a/core/Api/Request.class.php +++ b/core/Api/Request.class.php @@ -183,7 +183,7 @@ abstract class Request { $authHeader = $_SERVER["HTTP_AUTHORIZATION"]; if (startsWith($authHeader, "Bearer ")) { $apiKey = substr($authHeader, strlen("Bearer ")); - $apiKeyAuthorized = $this->user->authorize($apiKey); + $apiKeyAuthorized = $this->user->loadApiKey($apiKey); } } } diff --git a/core/Configuration/Settings.class.php b/core/Configuration/Settings.class.php index 1e9f14e..1f55dd0 100644 --- a/core/Configuration/Settings.class.php +++ b/core/Configuration/Settings.class.php @@ -25,6 +25,7 @@ class Settings { private string $recaptchaPrivateKey; private string $mailSender; private string $mailFooter; + private array $allowedExtensions; public function getJwtSecret(): string { return $this->jwtSecret; @@ -51,6 +52,7 @@ class Settings { $settings->mailEnabled = false; $settings->mailSender = "webmaster@localhost"; $settings->mailFooter = ""; + $settings->allowedExtensions = ['png', 'jpg', 'jpeg', 'gif', 'htm', 'html']; return $settings; } @@ -72,6 +74,7 @@ class Settings { $this->mailEnabled = $result["mail_enabled"] ?? $this->mailEnabled; $this->mailSender = $result["mail_from"] ?? $this->mailSender; $this->mailFooter = $result["mail_footer"] ?? $this->mailFooter; + $this->allowedExtensions = explode(",", $result["allowed_extensions"] ?? strtolower(implode(",", $this->allowedExtensions))); if (!isset($result["jwt_secret"])) { $req = new \Api\Settings\Set($user); @@ -92,7 +95,8 @@ class Settings { ->addRow("jwt_secret", $this->jwtSecret, true, true) ->addRow("recaptcha_enabled", $this->recaptchaEnabled ? "1" : "0", false, false) ->addRow("recaptcha_public_key", $this->recaptchaPublicKey, false, false) - ->addRow("recaptcha_private_key", $this->recaptchaPrivateKey, true, false); + ->addRow("recaptcha_private_key", $this->recaptchaPrivateKey, true, false) + ->addRow("allowed_extensions", implode(",", $this->allowedExtensions), true, false); } public function getSiteName(): string { @@ -126,4 +130,8 @@ class Settings { public function getMailSender(): string { return $this->mailSender; } + + public function isExtensionAllowed(string $ext): bool { + return empty($this->allowedExtensions) || in_array(strtolower(trim($ext)), $this->allowedExtensions); + } } \ No newline at end of file diff --git a/core/Driver/SQL/MySQL.class.php b/core/Driver/SQL/MySQL.class.php index e49f1b4..e97d4f4 100644 --- a/core/Driver/SQL/MySQL.class.php +++ b/core/Driver/SQL/MySQL.class.php @@ -32,7 +32,7 @@ use Driver\SQL\Type\Trigger; class MySQL extends SQL { public function __construct($connectionData) { - parent::__construct($connectionData); + parent::__construct($connectionData); } public function checkRequirements() { @@ -46,7 +46,7 @@ class MySQL extends SQL { // Connection Management public function connect() { - if(!is_null($this->connection)) { + if (!is_null($this->connection)) { return true; } @@ -69,7 +69,7 @@ class MySQL extends SQL { } public function disconnect() { - if(is_null($this->connection)) { + if (is_null($this->connection)) { return true; } @@ -88,9 +88,9 @@ class MySQL extends SQL { private function getPreparedParams($values): array { $sqlParams = array(''); - foreach($values as $value) { + foreach ($values as $value) { $paramType = Parameter::parseType($value); - switch($paramType) { + switch ($paramType) { case Parameter::TYPE_BOOLEAN: $value = $value ? 1 : 0; $sqlParams[0] .= 'i'; @@ -134,6 +134,9 @@ class MySQL extends SQL { return $sqlParams; } + /** + * @return mixed + */ protected function execute($query, $values = NULL, int $fetchType = self::FETCH_NONE) { $result = null; @@ -218,7 +221,7 @@ class MySQL extends SQL { return ""; } else if ($strategy instanceof UpdateStrategy) { $updateValues = array(); - foreach($strategy->getValues() as $key => $value) { + foreach ($strategy->getValues() as $key => $value) { $leftColumn = $this->columnName($key); if ($value instanceof Column) { $columnName = $this->columnName($value->getName()); @@ -253,16 +256,16 @@ class MySQL extends SQL { } else { return "TEXT"; } - } else if($column instanceof SerialColumn) { + } else if ($column instanceof SerialColumn) { return "INTEGER AUTO_INCREMENT"; - } else if($column instanceof IntColumn) { + } else if ($column instanceof IntColumn) { $unsigned = $column->isUnsigned() ? " UNSIGNED" : ""; return $column->getType() . $unsigned; - } else if($column instanceof DateTimeColumn) { + } else if ($column instanceof DateTimeColumn) { return "DATETIME"; - } else if($column instanceof BoolColumn) { + } else if ($column instanceof BoolColumn) { return "BOOLEAN"; - } else if($column instanceof JsonColumn) { + } else if ($column instanceof JsonColumn) { return "LONGTEXT"; # some maria db setups don't allow JSON hereā€¦ } else { $this->lastError = $this->logger->error("Unsupported Column Type: " . get_class($column)); @@ -275,7 +278,7 @@ class MySQL extends SQL { $defaultValue = $column->getDefaultValue(); if ($column instanceof EnumColumn) { // check this, shouldn't it be in getColumnType? $values = array(); - foreach($column->getValues() as $value) { + foreach ($column->getValues() as $value) { $values[] = $this->getValueDefinition($value); } @@ -305,11 +308,11 @@ class MySQL extends SQL { public function getValueDefinition($value) { if (is_numeric($value)) { return $value; - } else if(is_bool($value)) { + } else if (is_bool($value)) { return $value ? "TRUE" : "FALSE"; - } else if(is_null($value)) { + } else if (is_null($value)) { return "NULL"; - } else if($value instanceof Keyword) { + } else if ($value instanceof Keyword) { return $value->getValue(); } else if ($value instanceof CurrentTimeStamp) { return "CURRENT_TIMESTAMP"; @@ -341,7 +344,7 @@ class MySQL extends SQL { public function tableName($table): string { if (is_array($table)) { $tables = array(); - foreach($table as $t) $tables[] = $this->tableName($t); + foreach ($table as $t) $tables[] = $this->tableName($t); return implode(",", $tables); } else { $parts = explode(" ", $table); @@ -357,16 +360,16 @@ class MySQL extends SQL { public function columnName($col): string { if ($col instanceof Keyword) { return $col->getValue(); - } elseif(is_array($col)) { + } elseif (is_array($col)) { $columns = array(); - foreach($col as $c) $columns[] = $this->columnName($c); + foreach ($col as $c) $columns[] = $this->columnName($c); return implode(",", $columns); } else { if (($index = strrpos($col, ".")) !== FALSE) { $tableName = $this->tableName(substr($col, 0, $index)); $columnName = $this->columnName(substr($col, $index + 1)); return "$tableName.$columnName"; - } else if(($index = stripos($col, " as ")) !== FALSE) { + } else if (($index = stripos($col, " as ")) !== FALSE) { $columnName = $this->columnName(trim(substr($col, 0, $index))); $alias = trim(substr($col, $index + 4)); return "$columnName as $alias"; diff --git a/core/Driver/SQL/PostgreSQL.class.php b/core/Driver/SQL/PostgreSQL.class.php index e2351a2..5a53943 100644 --- a/core/Driver/SQL/PostgreSQL.class.php +++ b/core/Driver/SQL/PostgreSQL.class.php @@ -92,6 +92,9 @@ class PostgreSQL extends SQL { return $lastError; } + /** + * @return mixed + */ protected function execute($query, $values = NULL, int $fetchType = self::FETCH_NONE) { $this->lastError = ""; diff --git a/core/Driver/SQL/Query/Select.class.php b/core/Driver/SQL/Query/Select.class.php index b0d254c..3073ffb 100644 --- a/core/Driver/SQL/Query/Select.class.php +++ b/core/Driver/SQL/Query/Select.class.php @@ -109,6 +109,9 @@ class Select extends Query { return $this; } + /** + * @return mixed + */ public function execute() { return $this->sql->executeQuery($this, $this->fetchType); } diff --git a/core/Driver/SQL/SQL.class.php b/core/Driver/SQL/SQL.class.php index 0339b5b..8ebf748 100644 --- a/core/Driver/SQL/SQL.class.php +++ b/core/Driver/SQL/SQL.class.php @@ -121,6 +121,11 @@ abstract class SQL { public abstract function connect(); public abstract function disconnect(); + /** + * @param Query $query + * @param int $fetchType + * @return mixed + */ public function executeQuery(Query $query, int $fetchType = self::FETCH_NONE) { $parameters = []; @@ -242,6 +247,9 @@ abstract class SQL { } // Statements + /** + * @return mixed + */ protected abstract function execute($query, $values = NULL, int $fetchType = self::FETCH_NONE); public function buildCondition($condition, &$params) { diff --git a/core/Elements/Document.class.php b/core/Elements/Document.class.php index 72a159c..3ac8991 100644 --- a/core/Elements/Document.class.php +++ b/core/Elements/Document.class.php @@ -3,6 +3,7 @@ namespace Elements; use Configuration\Settings; +use Driver\Logger\Logger; use Driver\SQL\SQL; use Objects\Router\Router; use Objects\User; @@ -10,6 +11,7 @@ use Objects\User; abstract class Document { protected Router $router; + private Logger $logger; protected bool $databaseRequired; private bool $cspEnabled; private ?string $cspNonce; @@ -23,6 +25,11 @@ abstract class Document { $this->databaseRequired = true; $this->cspWhitelist = []; $this->domain = $this->getSettings()->getBaseUrl(); + $this->logger = new Logger("Document", $this->getSQL()); + } + + public function getLogger(): Logger { + return $this->logger; } public function getUser(): User { diff --git a/core/Elements/EmptyBody.class.php b/core/Elements/EmptyBody.class.php deleted file mode 100644 index f762407..0000000 --- a/core/Elements/EmptyBody.class.php +++ /dev/null @@ -1,7 +0,0 @@ -"; + $content = []; - foreach($this->metas as $aMeta) { - $header .= ' $val) { - $header .= " $key=\"$val\""; - } - $header .= ' />'; + // meta tags + foreach($this->metas as $meta) { + $content[] = html_tag_short("meta", $meta); } + // description if(!empty($this->description)) { - $header .= "description\" />"; + $content[] = html_tag_short("meta", ["name" => "description", "content" => $this->description]); } + // keywords if(!empty($this->keywords)) { $keywords = implode(", ", $this->keywords); - $header .= ""; + $content[] = html_tag_short("meta", ["name" => "keywords", "content" => $keywords]); } + // base tag if(!empty($this->baseUrl)) { - $header .= "baseUrl\">"; + $content[] = html_tag_short("base", ["href" => $this->baseUrl]); } - $header .= "$this->title"; + // title + $content[] = html_tag("title", [], $this->title); + // src tags foreach($this->sources as $src) { - $header .= $src->getCode(); + $content[] = $src->getCode(); } - foreach($this->rawFields as $raw) { - $header .= $raw; + // + foreach ($this->rawFields as $raw) { + $content[] = $raw; } - $header .= ""; - return $header; + return html_tag("head", [], $content, false); } } diff --git a/core/Elements/HtmlDocument.class.php b/core/Elements/HtmlDocument.class.php index 74839c5..2a3707a 100644 --- a/core/Elements/HtmlDocument.class.php +++ b/core/Elements/HtmlDocument.class.php @@ -28,7 +28,7 @@ class HtmlDocument extends Document { $view = parseClass($this->activeView); $file = getClassPath($view); - if(!file_exists($file) || !is_subclass_of($view, View::class)) { + if (!file_exists($file) || !is_subclass_of($view, View::class)) { return null; } @@ -67,12 +67,10 @@ class HtmlDocument extends Document { $head = $this->head->getCode(); $lang = $this->getUser()->getLanguage()->getShortCode(); - $html = ""; - $html .= ""; - $html .= $head; - $html .= $body; - $html .= ""; - return $html; + $code = ""; + $code .= html_tag("html", ["lang" => $lang], $head . $body, false); + + return $code; } diff --git a/core/Elements/Link.class.php b/core/Elements/Link.class.php index b8e67aa..1c4f19d 100644 --- a/core/Elements/Link.class.php +++ b/core/Elements/Link.class.php @@ -34,8 +34,7 @@ class Link extends StaticView { $attributes["nonce"] = $this->nonce; } - $attributes = html_attributes($attributes); - return ""; + return html_tag_short("link", $attributes); } public function setNonce(string $nonce) { diff --git a/core/Elements/Script.class.php b/core/Elements/Script.class.php index 7b97d68..305d688 100644 --- a/core/Elements/Script.class.php +++ b/core/Elements/Script.class.php @@ -35,8 +35,8 @@ class Script extends StaticView { $attributes["nonce"] = $this->nonce; } - $attributes = html_attributes($attributes); - return ""; + // TODO: do we need to escape the content here? + return html_tag("script", $attributes, $this->content, false); } public function setNonce(string $nonce) { diff --git a/core/Elements/SimpleBody.class.php b/core/Elements/SimpleBody.class.php index 171efce..1c51ad0 100644 --- a/core/Elements/SimpleBody.class.php +++ b/core/Elements/SimpleBody.class.php @@ -10,7 +10,7 @@ abstract class SimpleBody extends Body { public function getCode(): string { $content = $this->getContent(); - return parent::getCode() . "$content"; + return html_tag("body", [], $content, false); } protected abstract function getContent(): string; diff --git a/core/Elements/Style.class.php b/core/Elements/Style.class.php index 9775a9d..a7b34fe 100644 --- a/core/Elements/Style.class.php +++ b/core/Elements/Style.class.php @@ -11,6 +11,7 @@ class Style extends StaticView { } function getCode(): string { - return ""; + // TODO: do we need to escape the content here? + return html_tag("style", [], $this->style, false); } } diff --git a/core/Elements/View.class.php b/core/Elements/View.class.php index 3fec10d..79b5aca 100644 --- a/core/Elements/View.class.php +++ b/core/Elements/View.class.php @@ -36,7 +36,7 @@ abstract class View extends StaticView { return $view; } } catch(\ReflectionException $e) { - error_log($e->getMessage()); + $this->document->getLogger()->error("Error loading view: '$viewClass': " . $e->getMessage()); } return ""; @@ -44,7 +44,7 @@ abstract class View extends StaticView { private function loadLanguageModules() { $lang = $this->document->getUser()->getLanguage(); - foreach($this->langModules as $langModule) { + foreach ($this->langModules as $langModule) { $lang->loadModule($langModule); } } @@ -57,7 +57,7 @@ abstract class View extends StaticView { // Load translations $this->loadLanguageModules(); - // Load Meta Data + Head (title, scripts, includes, ...) + // Load metadata + head (title, scripts, includes, ...) if($this->loadView) { $this->loadView(); } @@ -66,46 +66,46 @@ abstract class View extends StaticView { } // UI Functions - private function createList($items, $tag, $classes = ""): string { + private function createList(array $items, string $tag, array $classes = []): string { - $class = ($classes ? " class=\"$classes\"" : ""); - - if(count($items) === 0) { - return "<$tag$class>"; - } else { - return "<$tag$class>
  • " . implode("
  • ", $items) . "
  • "; + $attributes = []; + if (!empty($classes)) { + $attributes["class"] = implode(" ", $classes); } + + $content = array_map(function ($item) { html_tag("li", [], $item, false); }, $items); + return html_tag_ex($tag, $attributes, $content, false); } - public function createOrderedList($items=array(), $classes = ""): string { + public function createOrderedList(array $items=[], array $classes=[]): string { return $this->createList($items, "ol", $classes); } - public function createUnorderedList($items=array(), $classes = ""): string { + public function createUnorderedList(array $items=[], array $classes=[]): string { return $this->createList($items, "ul", $classes); } - protected function createLink($link, $title=null, $classes=""): string { - if(is_null($title)) $title=$link; - if(!empty($classes)) $classes = " class=\"$classes\""; - return "
    $title"; + 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($link, $title=null): string { - if(is_null($title)) $title=$link; - return "$title"; + 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 { - $iconClass = "$type fa-$icon"; + protected function createIcon($icon, $type="fas", $classes = []): string { + $classes = array_merge($classes, [$type, "fa-$icon"]); + if ($icon === "spinner" || $icon === "circle-notch") { + $classes[] = "fa-spin"; + } - if($icon === "spinner" || $icon === "circle-notch") - $iconClass .= " fa-spin"; - - if($classes) - $iconClass .= " $classes"; - - return ""; + return html_tag("i", ["class" => implode(" ", $classes)]); } protected function createErrorText($text, $id="", $hidden=false): string { @@ -128,87 +128,23 @@ abstract class View extends StaticView { return $this->createStatusText("info", $text, $id, $hidden); } - protected function createStatusText($type, $text, $id="", $hidden=false, $classes=""): string { - if(strlen($id) > 0) $id = " id=\"$id\""; - if($hidden) $classes .= " hidden"; - if(strlen($classes) > 0) $classes = " $classes"; - return "
    $text
    "; - } + protected function createStatusText(string $type, $text, string $id="", bool $hidden=false, array $classes=[]): string { + $classes[] = "alert"; + $classes[] = "alert-$type"; - protected function createBadge($type, $text): string { - $text = htmlspecialchars($text); - return "$text"; - } - - protected function createJumbotron(string $content, bool $fluid=false, $class=""): string { - $jumbotronClass = "jumbotron" . ($fluid ? " jumbotron-fluid" : ""); - if (!empty($class)) $jumbotronClass .= " $class"; - - return - "
    - $content -
    "; - } - - public function createSimpleParagraph(string $content, string $class=""): string { - if($class) $class = " class=\"$class\""; - return "$content

    "; - } - - public function createParagraph($title, $id, $content): string { - $id = replaceCssSelector($id); - $iconId = urlencode("$id-icon"); - return " -
    -
    -

    $title

    -
    - $content -
    -
    "; - } - - protected function createBootstrapTable($data, string $classes=""): string { - $classes = empty($classes) ? "" : " $classes"; - $code = "
    "; - foreach($data as $row) { - $code .= "
    "; - $columnCount = count($row); - if($columnCount > 0) { - $remainingSize = 12; - $columnSize = 12 / $columnCount; - foreach($row as $col) { - $size = ($columnSize <= $remainingSize ? $columnSize : $remainingSize); - $content = $col; - $class = ""; - $code .= " $val) { - if(strcmp($key, "content") === 0) { - $content = $val; - } else if(strcmp($key, "class") === 0) { - $class = " " . $col["class"]; - } else if(strcmp($key, "cols") === 0 && is_numeric($val)) { - $size = intval($val); - } else { - $code .= " $key=\"$val\""; - } - } - - if(isset($col["class"])) $class = " " . $col["class"]; - } - - if($size <= 6) $class .= " col-md-" . intval($size * 2); - $code .= " class=\"col-lg-$size$class\">$content
    "; - $remainingSize -= $size; - } - } - $code .= "
    "; + if ($hidden) { + $classes[] = "hidden"; } - $code .= "
    "; - return $code; + $attributes = [ + "class" => implode(" ", $classes), + "role" => "alert" + ]; + + if (!empty($id)) { + $attributes["id"] = $id; + } + + return html_tag("div", $attributes, $text, false); } } diff --git a/core/Objects/Router/Router.class.php b/core/Objects/Router/Router.class.php index 6320942..a479da6 100644 --- a/core/Objects/Router/Router.class.php +++ b/core/Objects/Router/Router.class.php @@ -7,18 +7,22 @@ use Objects\User; class Router { - private User $user; + private ?User $user; private Logger $logger; protected array $routes; protected array $statusCodeRoutes; - public function __construct(User $user) { + public function __construct(?User $user = null) { $this->user = $user; - $this->logger = new Logger("Router", $user->getSQL()); $this->routes = []; $this->statusCodeRoutes = []; - $this->addRoute(new ApiRoute()); + if ($user) { + $this->addRoute(new ApiRoute()); + $this->logger = new Logger("Router", $user->getSQL()); + } else { + $this->logger = new Logger("Router"); + } } public function run(string $url): string { diff --git a/core/Objects/Router/StaticFileRoute.class.php b/core/Objects/Router/StaticFileRoute.class.php index 3c67a65..a548bd9 100644 --- a/core/Objects/Router/StaticFileRoute.class.php +++ b/core/Objects/Router/StaticFileRoute.class.php @@ -15,10 +15,61 @@ class StaticFileRoute extends AbstractRoute { public function call(Router $router, array $params): string { http_response_code($this->code); - return serveStatic(WEBROOT, $this->path); + $this->serveStatic($this->path, $router); + return ""; } protected function getArgs(): array { return array_merge(parent::getArgs(), [$this->path, $this->code]); } + + public static function serveStatic(string $path, ?Router $router = null) { + + $path = realpath(WEBROOT . DIRECTORY_SEPARATOR . $path); + if (!startsWith($path, WEBROOT . DIRECTORY_SEPARATOR)) { + http_response_code(406); + echo "Access restricted, requested file outside web root: " . htmlspecialchars($path); + } + + if (!file_exists($path) || !is_file($path) || !is_readable($path)) { + http_response_code(500); + echo "Unable to read file: " . htmlspecialchars($path); + } + + $pathInfo = pathinfo($path); + if ($router !== null && ($user = $router->getUser()) !== null) { + $ext = $pathInfo["extension"] ?? ""; + if (!$user->getConfiguration()->getSettings()->isExtensionAllowed($ext)) { + http_response_code(406); + echo "Access restricted: Extension '" . htmlspecialchars($ext) . "' not allowed to serve."; + } + } + + $size = filesize($path); + $mimeType = mime_content_type($path); + header("Content-Type: $mimeType"); + header("Content-Length: $size"); + header('Accept-Ranges: bytes'); + + if (strcasecmp($_SERVER["REQUEST_METHOD"], "HEAD") !== 0) { + $handle = fopen($path, "rb"); + if ($handle === false) { + http_response_code(500); + echo "Unable to read file: " . htmlspecialchars($path); + } + + $offset = 0; + $length = $size; + + if (isset($_SERVER['HTTP_RANGE'])) { + preg_match('/bytes=(\d+)-(\d+)?/', $_SERVER['HTTP_RANGE'], $matches); + $offset = intval($matches[1]); + $length = intval($matches[2]) - $offset; + http_response_code(206); + header('Content-Range: bytes ' . $offset . '-' . ($offset + $length) . '/' . $size); + } + + downloadFile($handle, $offset, $length); + } + } } \ No newline at end of file diff --git a/core/Objects/Session.class.php b/core/Objects/Session.class.php index e95c2ee..e9c8e12 100644 --- a/core/Objects/Session.class.php +++ b/core/Objects/Session.class.php @@ -101,19 +101,20 @@ class Session extends ApiObject { $sql = $this->user->getSQL(); $minutes = Session::DURATION; - $columns = array("expires", "user_id", "ipAddress", "os", "browser", "data", "stay_logged_in", "csrf_token"); + $data = [ + "expires" => (new DateTime())->modify("+$minutes minute"), + "user_id" => $this->user->getId(), + "ipAddress" => $this->ipAddress, + "os" => $this->os, + "browser" => $this->browser, + "data" => json_encode($_SESSION ?? []), + "stay_logged_in" => $stayLoggedIn, + "csrf_token" => $this->csrfToken + ]; $success = $sql - ->insert("Session", $columns) - ->addRow( - (new DateTime())->modify("+$minutes minute"), - $this->user->getId(), - $this->ipAddress, - $this->os, - $this->browser, - json_encode($_SESSION ?? []), - $stayLoggedIn, - $this->csrfToken) + ->insert("Session", array_keys($data)) + ->addRow(...array_values($data)) ->returning("uid") ->execute(); @@ -149,7 +150,7 @@ class Session extends ApiObject { ->set("Session.ipAddress", $this->ipAddress) ->set("Session.os", $this->os) ->set("Session.browser", $this->browser) - ->set("Session.data", json_encode($_SESSION)) + ->set("Session.data", json_encode($_SESSION ?? [])) ->set("Session.csrf_token", $this->csrfToken) ->where(new Compare("Session.uid", $this->sessionId)) ->where(new Compare("Session.user_id", $this->user->getId())) diff --git a/core/Objects/User.class.php b/core/Objects/User.class.php index 427edec..6be59d2 100644 --- a/core/Objects/User.class.php +++ b/core/Objects/User.class.php @@ -3,14 +3,13 @@ namespace Objects; use Configuration\Configuration; +use Driver\SQL\Condition\CondAnd; use Exception; use External\JWT; use Driver\SQL\SQL; use Driver\SQL\Condition\Compare; -use Driver\SQL\Condition\CondBool; use Objects\TwoFactor\TwoFactorToken; -// TODO: User::authorize and User::readData have similar function body class User extends ApiObject { private ?SQL $sql; @@ -40,7 +39,7 @@ class User extends ApiObject { } public function __destruct() { - if($this->sql && $this->sql->isConnected()) { + if ($this->sql && $this->sql->isConnected()) { $this->sql->close(); } } @@ -61,21 +60,66 @@ class User extends ApiObject { return false; } - public function getId(): int { return $this->uid; } - public function isLoggedIn(): bool { return $this->loggedIn; } - public function getUsername(): string { return $this->username; } - public function getFullName(): string { return $this->fullName; } - public function getEmail(): ?string { return $this->email; } - public function getSQL(): ?SQL { return $this->sql; } - public function getLanguage(): Language { return $this->language; } - public function setLanguage(Language $language) { $this->language = $language; $language->load(); } - public function getSession(): ?Session { return $this->session; } - public function getConfiguration(): Configuration { return $this->configuration; } - public function getGroups(): array { return $this->groups; } - public function hasGroup(int $group): bool { return isset($this->groups[$group]); } - public function getGPG(): ?GpgKey { return $this->gpgKey; } - public function getTwoFactorToken(): ?TwoFactorToken { return $this->twoFactorToken; } - public function getProfilePicture() : ?string { return $this->profilePicture; } + public function getId(): int { + return $this->uid; + } + + public function isLoggedIn(): bool { + return $this->loggedIn; + } + + public function getUsername(): string { + return $this->username; + } + + public function getFullName(): string { + return $this->fullName; + } + + public function getEmail(): ?string { + return $this->email; + } + + public function getSQL(): ?SQL { + return $this->sql; + } + + public function getLanguage(): Language { + return $this->language; + } + + public function setLanguage(Language $language) { + $this->language = $language; + $language->load(); + } + + public function getSession(): ?Session { + return $this->session; + } + + public function getConfiguration(): Configuration { + return $this->configuration; + } + + public function getGroups(): array { + return $this->groups; + } + + public function hasGroup(int $group): bool { + return isset($this->groups[$group]); + } + + public function getGPG(): ?GpgKey { + return $this->gpgKey; + } + + public function getTwoFactorToken(): ?TwoFactorToken { + return $this->twoFactorToken; + } + + public function getProfilePicture(): ?string { + return $this->profilePicture; + } public function __debugInfo(): array { $debugInfo = array( @@ -83,7 +127,7 @@ class User extends ApiObject { 'language' => $this->language->getName(), ); - if($this->loggedIn) { + if ($this->loggedIn) { $debugInfo['uid'] = $this->uid; $debugInfo['username'] = $this->username; } @@ -107,7 +151,7 @@ class User extends ApiObject { ); } else { return array( - 'language' => $this->language->jsonSerialize(), + 'language' => $this->language->jsonSerialize(), ); } } @@ -135,11 +179,11 @@ class User extends ApiObject { } public function updateLanguage($lang): bool { - if($this->sql) { + if ($this->sql) { $request = new \Api\Language\Set($this); return $request->execute(array("langCode" => $lang)); } else { - return false; + return false; } } @@ -162,68 +206,25 @@ class User extends ApiObject { * @param bool $sessionUpdate update session information, including session's lifetime and browser information * @return bool true, if the data could be loaded */ - public function readData($userId, $sessionId, bool $sessionUpdate = true): bool { + public function loadSession($userId, $sessionId, bool $sessionUpdate = true): bool { - $res = $this->sql->select("User.name", "User.email", "User.fullName", - "User.profilePicture", - "User.gpg_id", "GpgKey.confirmed as gpg_confirmed", "GpgKey.fingerprint as gpg_fingerprint", - "GpgKey.expires as gpg_expires", "GpgKey.algorithm as gpg_algorithm", - "User.2fa_id", "2FA.confirmed as 2fa_confirmed", "2FA.data as 2fa_data", "2FA.type as 2fa_type", - "Language.uid as langId", "Language.code as langCode", "Language.name as langName", - "Session.data", "Session.stay_logged_in", "Session.csrf_token", "Group.uid as groupId", "Group.name as groupName") - ->from("User") - ->innerJoin("Session", "Session.user_id", "User.uid") - ->leftJoin("Language", "User.language_id", "Language.uid") - ->leftJoin("UserGroup", "UserGroup.user_id", "User.uid") - ->leftJoin("Group", "UserGroup.group_id", "Group.uid") - ->leftJoin("GpgKey", "User.gpg_id", "GpgKey.uid") - ->leftJoin("2FA", "User.2fa_id", "2FA.uid") - ->where(new Compare("User.uid", $userId)) - ->where(new Compare("Session.uid", $sessionId)) - ->where(new Compare("Session.active", true)) - ->where(new CondBool("Session.stay_logged_in"), new Compare("Session.expires", $this->sql->currentTimestamp(), '>')) - ->execute(); + $userRow = $this->loadUser("Session", ["Session.data", "Session.stay_logged_in", "Session.csrf_token"], [ + new Compare("User.uid", $userId), + new Compare("Session.uid", $sessionId), + new Compare("Session.active", true), + ]); - $success = ($res !== FALSE); - if($success) { - if(empty($res)) { - $success = false; - } else { - $row = $res[0]; - $csrfToken = $row["csrf_token"]; - $this->username = $row['name']; - $this->email = $row["email"]; - $this->fullName = $row["fullName"]; - $this->uid = $userId; - $this->profilePicture = $row["profilePicture"]; - - $this->session = new Session($this, $sessionId, $csrfToken); - $this->session->setData(json_decode($row["data"] ?? '{}', true)); - $this->session->stayLoggedIn($this->sql->parseBool($row["stay_logged_in"])); - if ($sessionUpdate) $this->session->update(); - $this->loggedIn = true; - - if (!empty($row["gpg_id"])) { - $this->gpgKey = new GpgKey($row["gpg_id"], $this->sql->parseBool($row["gpg_confirmed"]), - $row["gpg_fingerprint"], $row["gpg_algorithm"], $row["gpg_expires"]); - } - - if (!empty($row["2fa_id"])) { - $this->twoFactorToken = TwoFactorToken::newInstance($row["2fa_type"], $row["2fa_data"], - $row["2fa_id"], $this->sql->parseBool($row["2fa_confirmed"])); - } - - if(!is_null($row['langId'])) { - $this->setLanguage(Language::newInstance($row['langId'], $row['langCode'], $row['langName'])); - } - - foreach($res as $row) { - $this->groups[$row["groupId"]] = $row["groupName"]; - } + if ($userRow !== false) { + $this->session = new Session($this, $sessionId, $userRow["csrf_token"]); + $this->session->setData(json_decode($userRow["data"] ?? '{}', true)); + $this->session->stayLoggedIn($this->sql->parseBool($userRow["stay_logged_in"])); + if ($sessionUpdate) { + $this->session->update(); } + $this->loggedIn = true; } - return $success; + return $userRow !== false; } private function parseCookies() { @@ -232,21 +233,21 @@ class User extends ApiObject { $token = $_COOKIE['session']; $settings = $this->configuration->getSettings(); $decoded = (array)JWT::decode($token, $settings->getJwtSecret()); - if(!is_null($decoded)) { + if (!is_null($decoded)) { $userId = ($decoded['userId'] ?? NULL); $sessionId = ($decoded['sessionId'] ?? NULL); - if(!is_null($userId) && !is_null($sessionId)) { - $this->readData($userId, $sessionId); + if (!is_null($userId) && !is_null($sessionId)) { + $this->loadSession($userId, $sessionId); } } - } catch(Exception $e) { + } catch (Exception $e) { // ignored } } - if(isset($_GET['lang']) && is_string($_GET["lang"]) && !empty($_GET["lang"])) { + if (isset($_GET['lang']) && is_string($_GET["lang"]) && !empty($_GET["lang"])) { $this->updateLanguage($_GET['lang']); - } else if(isset($_COOKIE['lang']) && is_string($_COOKIE["lang"]) && !empty($_COOKIE["lang"])) { + } else if (isset($_COOKIE['lang']) && is_string($_COOKIE["lang"]) && !empty($_COOKIE["lang"])) { $this->updateLanguage($_COOKIE['lang']); } } @@ -262,69 +263,95 @@ class User extends ApiObject { return false; } - public function authorize($apiKey): bool { + private function loadUser(string $table, array $columns, array $conditions) { + $userRow = $this->sql->select( + // User meta + "User.uid as userId", "User.name", "User.email", "User.fullName", "User.profilePicture", "User.confirmed", + + // GPG + "User.gpg_id", "GpgKey.confirmed as gpg_confirmed", "GpgKey.fingerprint as gpg_fingerprint", + "GpgKey.expires as gpg_expires", "GpgKey.algorithm as gpg_algorithm", + + // 2FA + "User.2fa_id", "2FA.confirmed as 2fa_confirmed", "2FA.data as 2fa_data", "2FA.type as 2fa_type", + + // Language + "Language.uid as langId", "Language.code as langCode", "Language.name as langName", + + // additional data + ...$columns) + ->from("User") + ->innerJoin("$table", "$table.user_id", "User.uid") + ->leftJoin("Language", "User.language_id", "Language.uid") + ->leftJoin("GpgKey", "User.gpg_id", "GpgKey.uid") + ->leftJoin("2FA", "User.2fa_id", "2FA.uid") + ->where(new CondAnd(...$conditions)) + ->first() + ->execute(); + + if ($userRow === null || $userRow === false) { + return false; + } + + // Meta data + $userId = $userRow["userId"]; + $this->uid = $userId; + $this->username = $userRow['name']; + $this->fullName = $userRow["fullName"]; + $this->email = $userRow['email']; + $this->profilePicture = $userRow["profilePicture"]; + + // GPG + if (!empty($userRow["gpg_id"])) { + $this->gpgKey = new GpgKey($userRow["gpg_id"], $this->sql->parseBool($userRow["gpg_confirmed"]), + $userRow["gpg_fingerprint"], $userRow["gpg_algorithm"], $userRow["gpg_expires"] + ); + } + + // 2FA + if (!empty($userRow["2fa_id"])) { + $this->twoFactorToken = TwoFactorToken::newInstance($userRow["2fa_type"], $userRow["2fa_data"], + $userRow["2fa_id"], $this->sql->parseBool($userRow["2fa_confirmed"])); + } + + // Language + if (!is_null($userRow['langId'])) { + $this->setLanguage(Language::newInstance($userRow['langId'], $userRow['langCode'], $userRow['langName'])); + } + + // select groups + $groupRows = $this->sql->select("Group.uid as groupId", "Group.name as groupName") + ->from("UserGroup") + ->where(new Compare("UserGroup.user_id", $userId)) + ->innerJoin("Group", "UserGroup.group_id", "Group.uid") + ->execute(); + if (is_array($groupRows)) { + foreach ($groupRows as $row) { + $this->groups[$row["groupId"]] = $row["groupName"]; + } + } + + return $userRow; + } + + public function loadApiKey($apiKey): bool { if ($this->loggedIn) { return true; } - $res = $this->sql->select("ApiKey.user_id as uid", "User.name", "User.fullName", "User.email", - "User.confirmed", "User.profilePicture", - "User.gpg_id", "GpgKey.fingerprint as gpg_fingerprint", "GpgKey.expires as gpg_expires", - "GpgKey.confirmed as gpg_confirmed", "GpgKey.algorithm as gpg_algorithm", - "User.2fa_id", "2FA.confirmed as 2fa_confirmed", "2FA.data as 2fa_data", "2FA.type as 2fa_type", - "Language.uid as langId", "Language.code as langCode", "Language.name as langName", - "Group.uid as groupId", "Group.name as groupName") - ->from("ApiKey") - ->innerJoin("User", "ApiKey.user_id", "User.uid") - ->leftJoin("UserGroup", "UserGroup.user_id", "User.uid") - ->leftJoin("Group", "UserGroup.group_id", "Group.uid") - ->leftJoin("Language", "User.language_id", "Language.uid") - ->leftJoin("GpgKey", "User.gpg_id", "GpgKey.uid") - ->leftJoin("2FA", "User.2fa_id", "2FA.uid") - ->where(new Compare("ApiKey.api_key", $apiKey)) - ->where(new Compare("valid_until", $this->sql->currentTimestamp(), ">")) - ->where(new Compare("ApiKey.active", 1)) - ->execute(); + $userRow = $this->loadUser("ApiKey", [], [ + new Compare("ApiKey.api_key", $apiKey), + new Compare("valid_until", $this->sql->currentTimestamp(), ">"), + new Compare("ApiKey.active", 1), + ]); - $success = ($res !== FALSE); - if ($success) { - if (empty($res) || !is_array($res)) { - $success = false; - } else { - $row = $res[0]; - if (!$this->sql->parseBool($row["confirmed"])) { - return false; - } - - $this->uid = $row['uid']; - $this->username = $row['name']; - $this->fullName = $row["fullName"]; - $this->email = $row['email']; - $this->profilePicture = $row["profilePicture"]; - - if (!empty($row["gpg_id"])) { - $this->gpgKey = new GpgKey($row["gpg_id"], $this->sql->parseBool($row["gpg_confirmed"]), - $row["gpg_fingerprint"], $row["gpg_algorithm"], $row["gpg_expires"] - ); - } - - if (!empty($row["2fa_id"])) { - $this->twoFactorToken = TwoFactorToken::newInstance($row["2fa_type"], $row["2fa_data"], - $row["2fa_id"], $this->sql->parseBool($row["2fa_confirmed"])); - } - - if(!is_null($row['langId'])) { - $this->setLanguage(Language::newInstance($row['langId'], $row['langCode'], $row['langName'])); - } - - foreach($res as $row) { - $this->groups[$row["groupId"]] = $row["groupName"]; - } - } + // User must be confirmed to use API-Keys + if ($userRow === false || !$this->sql->parseBool($userRow["confirmed"])) { + return false; } - return $success; + return true; } public function processVisit() { @@ -340,7 +367,7 @@ class User extends ApiObject { } private function isBot(): bool { - if (!isset($_SERVER["HTTP_USER_AGENT"]) || empty($_SERVER["HTTP_USER_AGENT"])) { + if (empty($_SERVER["HTTP_USER_AGENT"])) { return false; } diff --git a/core/core.php b/core/core.php index 8f94107..c8b0e76 100644 --- a/core/core.php +++ b/core/core.php @@ -5,10 +5,10 @@ if (is_file($autoLoad)) { require_once $autoLoad; } -define("WEBBASE_VERSION", "1.5.1"); +define("WEBBASE_VERSION", "1.5.2"); spl_autoload_extensions(".php"); -spl_autoload_register(function($class) { +spl_autoload_register(function ($class) { if (!class_exists($class)) { $suffixes = ["", ".class", ".trait"]; foreach ($suffixes as $suffix) { @@ -29,8 +29,8 @@ function is_cli(): bool { function getProtocol(): string { $isSecure = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] == 'on') || - (!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] == 'https') || - (!empty($_SERVER['HTTP_X_FORWARDED_SSL']) && $_SERVER['HTTP_X_FORWARDED_SSL'] == 'on'); + (!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] == 'https') || + (!empty($_SERVER['HTTP_X_FORWARDED_SSL']) && $_SERVER['HTTP_X_FORWARDED_SSL'] == 'on'); return $isSecure ? 'https' : 'http'; } @@ -47,9 +47,9 @@ function generateRandomString($length, $type = "ascii"): string { $lowercase = "abcdefghijklmnopqrstuvwxyz"; $uppercase = strtoupper($lowercase); - $digits = "0123456789"; - $hex = $digits . substr($lowercase, 0, 6); - $ascii = $uppercase . $lowercase . $digits; + $digits = "0123456789"; + $hex = $digits . substr($lowercase, 0, 6); + $ascii = $uppercase . $lowercase . $digits; if ($length > 0) { $type = strtolower($type); @@ -135,7 +135,6 @@ function endsWith($haystack, $needle, bool $ignoreCase = false): bool { } - function contains($haystack, $needle, bool $ignoreCase = false): bool { if (is_array($haystack)) { @@ -191,10 +190,6 @@ function replaceCssSelector($sel) { return preg_replace("~[.#<>]~", "_", preg_replace("~[:\-]~", "", $sel)); } -function urlId($str) { - return urlencode(htmlspecialchars(preg_replace("[: ]","-", $str))); -} - function html_attributes(array $attributes): string { return implode(" ", array_map(function ($key) use ($attributes) { $value = htmlspecialchars($attributes[$key]); @@ -202,6 +197,31 @@ function html_attributes(array $attributes): string { }, array_keys($attributes))); } +function html_tag_short(string $tag, array $attributes = []): string { + return html_tag_ex($tag, $attributes, "", true, true); +} + +function html_tag(string $tag, array $attributes = [], $content = "", bool $escapeContent = true): string { + return html_tag_ex($tag, $attributes, $content, $escapeContent, false); +} + +function html_tag_ex(string $tag, array $attributes, $content = "", bool $escapeContent = true, bool $short = false): string { + $attrs = html_attributes($attributes); + if (!empty($attrs)) { + $attrs = " " . $attrs; + } + + if (is_array($content)) { + $content = implode("", $content); + } + + if ($escapeContent) { + $content = htmlspecialchars($content); + } + + return ($short && !empty($content)) ? "<$tag$attrs/>" : "<$tag$attrs>$content"; +} + function getClassPath($class, string $suffix = ".class"): string { $path = str_replace('\\', '/', $class); $path = array_values(array_filter(explode("/", $path))); @@ -229,7 +249,7 @@ function createError($msg) { } function downloadFile($handle, $offset = 0, $length = null): bool { - if($handle === false) { + if ($handle === false) { return false; } @@ -238,7 +258,7 @@ function downloadFile($handle, $offset = 0, $length = null): bool { } $bytesRead = 0; - $bufferSize = 1024*16; + $bufferSize = 1024 * 16; while (!feof($handle) && ($length === null || $bytesRead < $length)) { $chunkSize = ($length === null ? $bufferSize : min($length - $bytesRead, $bufferSize)); echo fread($handle, $chunkSize); @@ -248,59 +268,6 @@ function downloadFile($handle, $offset = 0, $length = null): bool { return true; } -function serveStatic(string $webRoot, string $file): string { - - $path = realpath($webRoot . "/" . $file); - if (!startsWith($path, $webRoot . "/")) { - http_response_code(406); - return "Access restricted, requested file outside web root: " . htmlspecialchars($path); - } - - if (!file_exists($path) || !is_file($path) || !is_readable($path)) { - http_response_code(500); - return "Unable to read file: " . htmlspecialchars($path); - } - - $pathInfo = pathinfo($path); - - // TODO: add more file extensions here, probably add them to settings? - $allowedExtension = array("html", "htm", "pdf"); - $ext = $pathInfo["extension"] ?? ""; - if (!in_array($ext, $allowedExtension)) { - http_response_code(406); - return "Access restricted: Extension '" . htmlspecialchars($ext) . "' not allowed."; - } - - $size = filesize($path); - $mimeType = mime_content_type($path); - header("Content-Type: $mimeType"); // TODO: do we need to check mime type? - header("Content-Length: $size"); - header('Accept-Ranges: bytes'); - - if (strcasecmp($_SERVER["REQUEST_METHOD"], "HEAD") !== 0) { - $handle = fopen($path, "rb"); - if($handle === false) { - http_response_code(500); - return "Unable to read file: " . htmlspecialchars($path); - } - - $offset = 0; - $length = $size; - - if (isset($_SERVER['HTTP_RANGE'])) { - preg_match('/bytes=(\d+)-(\d+)?/', $_SERVER['HTTP_RANGE'], $matches); - $offset = intval($matches[1]); - $length = intval($matches[2]) - $offset; - http_response_code(206); - header('Content-Range: bytes ' . $offset . '-' . ($offset + $length) . '/' . $size); - } - - downloadFile($handle, $offset, $length); - } - - return ""; -} - function parseClass($class): string { if (!startsWith($class, "\\")) { $class = "\\$class"; diff --git a/index.php b/index.php index b0efd3b..89748a8 100644 --- a/index.php +++ b/index.php @@ -8,7 +8,7 @@ define("WEBROOT", realpath(".")); if (is_file("MAINTENANCE") && !in_array($_SERVER['REMOTE_ADDR'], ['127.0.0.1', '::1'])) { http_response_code(503); - serveStatic(WEBROOT, "/static/maintenance.html"); + \Objects\Router\StaticFileRoute::serveStatic("/static/maintenance.html"); die(); }