Browse Source

1.5.2: html functions, DB Row Iterator, and more

Roman 1 year ago
parent
commit
d8605597f6

+ 1 - 12
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(<li className={"nav-item"} key={"files"}>
-            <a href={filePath} className={"nav-link"} target={"_blank"} rel={"noopener"}>
-                <Icon icon={"folder"} className={"nav-icon"} />
-                <p>Files</p>
-            </a>
-        </li>);
-    }
-
     li.push(<li className={"nav-item"} key={"logout"}>
         <a href={"#"} onClick={() => onLogout()} className={"nav-link"}>
             <Icon icon={"arrow-left"} className={"nav-icon"} />

+ 2 - 26
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 <Router>
         <Header {...this.controlObj} notifications={this.state.notifications} />
-        <Sidebar {...this.controlObj} notifications={this.state.notifications} contactRequests={this.state.contactRequests} filesPath={this.state.filesPath} />
+        <Sidebar {...this.controlObj} notifications={this.state.notifications} contactRequests={this.state.contactRequests}/>
         <div className={"content-wrapper p-2"}>
           <section className={"content"}>
             <Switch>

+ 1 - 1
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);
           }
         }
       }

+ 9 - 1
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);
+  }
 }

+ 22 - 19
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";

+ 3 - 0
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 = "";

+ 3 - 0
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);
   }

+ 8 - 0
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) {

+ 7 - 0
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 {

+ 0 - 7
core/Elements/EmptyBody.class.php

@@ -1,7 +0,0 @@
-<?php
-
-use Elements\Body;
-
-class EmptyBody extends Body {
-
-}

+ 19 - 17
core/Elements/Head.class.php

@@ -69,40 +69,42 @@ abstract class Head extends View {
   }
 
   public function getCode(): string {
-    $header = "<head>";
-
-    foreach($this->metas as $aMeta) {
-      $header .= '<meta';
-      foreach($aMeta as $key => $val) {
-        $header .= " $key=\"$val\"";
-      }
-      $header .= ' />';
+    $content = [];
+
+    // meta tags
+    foreach($this->metas as $meta) {
+      $content[] = html_tag_short("meta", $meta);
     }
 
+    // description
     if(!empty($this->description)) {
-      $header .= "<meta name=\"description\" content=\"$this->description\" />";
+      $content[] = html_tag_short("meta", ["name" => "description", "content" => $this->description]);
     }
 
+    // keywords
     if(!empty($this->keywords)) {
       $keywords = implode(", ", $this->keywords);
-      $header .= "<meta name=\"keywords\" content=\"$keywords\" />";
+      $content[] = html_tag_short("meta", ["name" => "keywords", "content" => $keywords]);
     }
 
+    // base tag
     if(!empty($this->baseUrl)) {
-      $header .= "<base href=\"$this->baseUrl\">";
+      $content[] = html_tag_short("base", ["href" => $this->baseUrl]);
     }
 
-    $header .= "<title>$this->title</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 .= "</head>";
-    return $header;
+    return html_tag("head", [], $content, false);
   }
 }

+ 5 - 7
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 = "<!DOCTYPE html>";
-    $html .= "<html lang=\"$lang\">";
-    $html .= $head;
-    $html .= $body;
-    $html .= "</html>";
-    return $html;
+    $code = "<!DOCTYPE html>";
+    $code .= html_tag("html", ["lang" => $lang], $head . $body, false);
+
+    return $code;
   }
 
 

+ 1 - 2
core/Elements/Link.class.php

@@ -34,8 +34,7 @@ class Link extends StaticView {
       $attributes["nonce"] = $this->nonce;
     }
 
-    $attributes = html_attributes($attributes);
-    return "<link $attributes/>";
+    return html_tag_short("link", $attributes);
   }
 
   public function setNonce(string $nonce) {

+ 2 - 2
core/Elements/Script.class.php

@@ -35,8 +35,8 @@ class Script extends StaticView {
       $attributes["nonce"] = $this->nonce;
     }
 
-    $attributes = html_attributes($attributes);
-    return "<script $attributes>$this->content</script>";
+    // TODO: do we need to escape the content here?
+    return html_tag("script", $attributes, $this->content, false);
   }
 
   public function setNonce(string $nonce) {

+ 1 - 1
core/Elements/SimpleBody.class.php

@@ -10,7 +10,7 @@ abstract class SimpleBody extends Body {
 
   public function getCode(): string {
     $content = $this->getContent();
-    return parent::getCode() . "<body>$content</body>";
+    return html_tag("body", [], $content, false);
   }
 
   protected abstract function getContent(): string;

+ 2 - 1
core/Elements/Style.class.php

@@ -11,6 +11,7 @@ class Style extends StaticView {
   }
 
   function getCode(): string {
-    return "<style>$this->style</style>";
+    // TODO: do we need to escape the content here?
+    return html_tag("style", [], $this->style, false);
   }
 }

+ 44 - 108
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></$tag>";
-    } else {
-      return "<$tag$class><li>" . implode("</li><li>", $items) . "</li></$tag>";
+    $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 "<a href=\"$link\"$classes>$title</a>";
-  }
+  protected function createLink(string $link, $title=null, array $classes=[], bool $escapeTitle=true): string {
+    $attrs = ["href" => $link];
+    if (!empty($classes)) {
+      $attrs["class"] = implode(" ", $classes);
+    }
 
-  protected function createExternalLink($link, $title=null): string {
-    if(is_null($title)) $title=$link;
-    return "<a href=\"$link\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"external\">$title</a>";
+    return html_tag("a", $attrs, $title ?? $link, $escapeTitle);
   }
 
-  protected function createIcon($icon, $type = "fas", $classes = ""): string {
-    $iconClass = "$type fa-$icon";
-
-    if($icon === "spinner" || $icon === "circle-notch")
-      $iconClass .= " fa-spin";
+  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);
+  }
 
-    if($classes)
-      $iconClass .= " $classes";
+  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 "<i class=\"$iconClass\" ></i>";
+    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 "<div class=\"alert alert-$type$hidden$classes\" role=\"alert\"$id>$text</div>";
-  }
-
-  protected function createBadge($type, $text): string {
-    $text = htmlspecialchars($text);
-    return "<span class=\"badge badge-$type\">$text</span>";
-  }
-
-  protected function createJumbotron(string $content, bool $fluid=false, $class=""): string {
-    $jumbotronClass = "jumbotron" . ($fluid ? " jumbotron-fluid" : "");
-    if (!empty($class)) $jumbotronClass .= " $class";
-
-    return
-      "<div class=\"$jumbotronClass\">
-         $content
-      </div>";
-  }
-
-  public function createSimpleParagraph(string $content, string $class=""): string {
-    if($class) $class = " class=\"$class\"";
-    return "<p$class>$content</p>";
-  }
-
-  public function createParagraph($title, $id, $content): string {
-    $id = replaceCssSelector($id);
-    $iconId = urlencode("$id-icon");
-    return "
-      <div class=\"row mt-4\">
-        <div class=\"col-12\">
-          <h2 id=\"$id\" data-target=\"$iconId\" class=\"inlineLink\">$title</h2>
-          <hr/>
-          $content
-        </div>
-      </div>";
-  }
-
-  protected function createBootstrapTable($data, string $classes=""): string {
-    $classes = empty($classes) ? "" : " $classes";
-    $code = "<div class=\"container$classes\">";
-    foreach($data as $row) {
-      $code .= "<div class=\"row mt-2 mb-2\">";
-      $columnCount = count($row);
-      if($columnCount > 0) {
-        $remainingSize = 12;
-        $columnSize = 12 / $columnCount;
-        foreach($row as $col) {
-          $size = ($columnSize <= $remainingSize ? $columnSize : $remainingSize);
-          $content = $col;
-          $class = "";
-          $code .= "<div";
-
-          if(is_array($col)) {
-            $content = "";
-            foreach($col as $key => $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</div>";
-          $remainingSize -= $size;
-        }
-      }
-      $code .= "</div>";
+  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;
     }
 
-    $code .= "</div>";
-    return $code;
+    return html_tag("div", $attributes, $text, false);
   }
 }

+ 8 - 4
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 {

+ 52 - 1
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 "<b>Access restricted, requested file outside web root:</b> " . htmlspecialchars($path);
+    }
+
+    if (!file_exists($path) || !is_file($path) || !is_readable($path)) {
+      http_response_code(500);
+      echo "<b>Unable to read file:</b> " . 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 "<b>Access restricted:</b> 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 "<b>Unable to read file:</b> " . 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);
+    }
+  }
 }

+ 13 - 12
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()))

+ 162 - 135
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 {
-
-    $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();
-
-    $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"];
-        }
+  public function loadSession($userId, $sessionId, bool $sessionUpdate = true): bool {
+
+    $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),
+    ]);
+
+    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",
 
-    if ($this->loggedIn) {
-      return true;
-    }
+      // GPG
+      "User.gpg_id", "GpgKey.confirmed as gpg_confirmed", "GpgKey.fingerprint as gpg_fingerprint",
+      "GpgKey.expires as gpg_expires", "GpgKey.algorithm as gpg_algorithm",
 
-    $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",
+      // 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",
-      "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")
+
+      // 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 Compare("ApiKey.api_key", $apiKey))
-      ->where(new Compare("valid_until", $this->sql->currentTimestamp(), ">"))
-      ->where(new Compare("ApiKey.active", 1))
+      ->where(new CondAnd(...$conditions))
+      ->first()
       ->execute();
 
-    $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 ($userRow === null || $userRow === false) {
+      return false;
+    }
 
-        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"]
-          );
-        }
+    // 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"]
+      );
+    }
 
-        if (!empty($row["2fa_id"])) {
-          $this->twoFactorToken = TwoFactorToken::newInstance($row["2fa_type"], $row["2fa_data"],
-            $row["2fa_id"], $this->sql->parseBool($row["2fa_confirmed"]));
-        }
+    // 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"]));
+    }
 
-        if(!is_null($row['langId'])) {
-          $this->setLanguage(Language::newInstance($row['langId'], $row['langCode'], $row['langName']));
-        }
+    // Language
+    if (!is_null($userRow['langId'])) {
+      $this->setLanguage(Language::newInstance($userRow['langId'], $userRow['langCode'], $userRow['langName']));
+    }
 
-        foreach($res as $row) {
-          $this->groups[$row["groupId"]] = $row["groupName"];
-        }
+    // 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 $success;
+    return $userRow;
+  }
+
+  public function loadApiKey($apiKey): bool {
+
+    if ($this->loggedIn) {
+      return true;
+    }
+
+    $userRow = $this->loadUser("ApiKey", [], [
+      new Compare("ApiKey.api_key", $apiKey),
+      new Compare("valid_until", $this->sql->currentTimestamp(), ">"),
+      new Compare("ApiKey.active", 1),
+    ]);
+
+    // User must be confirmed to use API-Keys
+    if ($userRow === false || !$this->sql->parseBool($userRow["confirmed"])) {
+      return false;
+    }
+
+    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;
     }
 

+ 34 - 67
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</$tag>";
+}
+
 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 "<b>Access restricted, requested file outside web root:</b> " . htmlspecialchars($path);
-  }
-
-  if (!file_exists($path) || !is_file($path) || !is_readable($path)) {
-    http_response_code(500);
-    return "<b>Unable to read file:</b> " . 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 "<b>Access restricted:</b> 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 "<b>Unable to read file:</b> " . 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";

+ 1 - 1
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();
 }