Browse Source

Router, Logger, Bump v1.5

Roman 1 year ago
parent
commit
658157167e

+ 19 - 2
adminPanel/src/views/pages.js

@@ -91,6 +91,12 @@ export default class PageOverview extends React.Component {
                             checked={route.active === 1}
                             onChange={(e) => this.changeActive(i, e)} />
                     </td>
+                    <td className={"text-center"}>
+                        <input
+                            type={"checkbox"}
+                            checked={route.exact === 1}
+                            onChange={(e) => this.changeExact(i, e)} />
+                    </td>
                     <td>
                         <ReactTooltip id={"delete-" + i} />
                         <Icon icon={"trash"} style={{color: "red", cursor: "pointer"}}
@@ -158,6 +164,12 @@ export default class PageOverview extends React.Component {
                                                   data-tip={"True, if the route is currently active."}
                                                   data-type={"info"} data-place={"bottom"}/>
                                         </th>
+                                        <th className={"text-center"}>
+                                            Exact&nbsp;
+                                            <Icon icon={"question-circle"} style={{"color": "#17a2b8"}}
+                                                  data-tip={"True, if the URL must match exactly."}
+                                                  data-type={"info"} data-place={"bottom"}/>
+                                        </th>
                                         <th/>
                                     </tr>
                                 </thead>
@@ -200,7 +212,8 @@ export default class PageOverview extends React.Component {
                 action: typeof route.action === 'object' ? route.action.value : route.action,
                 target: route.target,
                 extra: route.extra ?? "",
-                active: route.active === 1
+                active: route.active === 1,
+                exact: route.exact === 1,
             });
         }
 
@@ -235,7 +248,7 @@ export default class PageOverview extends React.Component {
 
     onAddRoute() {
         let routes = this.state.routes.slice();
-        routes.push({ request: "", action: "dynamic", target: "", extra: "", active: 1 });
+        routes.push({ request: "", action: "dynamic", target: "", extra: "", active: 1, exact: 1 });
         this.setState({ ...this.state, routes: routes });
     }
 
@@ -247,6 +260,10 @@ export default class PageOverview extends React.Component {
         this.changeRoute(index, "active", e.target.checked ? 1 : 0);
     }
 
+    changeExact(index, e) {
+        this.changeRoute(index, "exact", e.target.checked ? 1 : 0);
+    }
+
     changeRequest(index, e) {
         this.changeRoute(index, "request", e.target.value);
     }

+ 10 - 4
cli.php

@@ -103,7 +103,7 @@ function handleDatabase(array $argv) {
     $user = getUser() or die();
     $sql = $user->getSQL();
     applyPatch($sql, $class);
-  } else if ($action === "export" || $action === "import") {
+  } else if (in_array($action, ["export", "import", "shell"])) {
 
     // database config
     $config = getDatabaseConfig();
@@ -147,6 +147,9 @@ function handleDatabase(array $argv) {
       } else if ($action === "import") {
         $command_bin = "mysql";
         $descriptorSpec[0] = ["pipe", "r"];
+      } else if ($action === "shell") {
+        $command_bin = "mysql";
+        $descriptorSpec = [];
       }
     } else if ($dbType === "postgres") {
 
@@ -161,6 +164,9 @@ function handleDatabase(array $argv) {
       } else if ($action === "import") {
         $command_bin = "/usr/bin/psql";
         $descriptorSpec[0] = ["pipe", "r"];
+      } else if ($action === "shell") {
+        $command_bin = "/usr/bin/psql";
+        $descriptorSpec = [];
       }
 
     } else {
@@ -173,7 +179,7 @@ function handleDatabase(array $argv) {
 
     $command = array_merge([$command_bin], $command_args);
     if ($config->getProperty("isDocker", false)) {
-      $command =  array_merge(["docker", "exec", "-it", "db"], $command);
+      $command = array_merge(["docker", "exec", "-it", "db"], $command);
     }
 
     $process = proc_open($command, $descriptorSpec, $pipes, null, $env);
@@ -227,7 +233,7 @@ function handleDatabase(array $argv) {
 
     printLine("Done!");
   } else {
-    _exit("Usage: cli.php db <migrate|import|export> [options...]");
+    _exit("Usage: cli.php db <migrate|import|export|shell> [options...]");
   }
 }
 
@@ -537,7 +543,7 @@ function onTest($argv) {
   $requestedTests = array_filter(array_slice($argv, 2), function ($t) {
     return !startsWith($t, "-");
   });
-  $verbose = in_array("-v", $requestedTests);
+  $verbose = in_array("-v", $argv);
 
   foreach ($files as $file) {
     include_once $file;

+ 0 - 2
core/.gitignore

@@ -1,2 +0,0 @@
-TemplateCache
-External/cache

+ 1 - 1
core/Api/NewsAPI.class.php

@@ -101,7 +101,7 @@ namespace Api\News {
         $this->result["newsId"] = $sql->getLastInsertId();
       }
 
-      return true;
+      return $this->success;
     }
   }
 

+ 17 - 1
core/Api/Request.class.php

@@ -2,13 +2,14 @@
 
 namespace Api;
 
-use Api\Parameter\Parameter;
+use Driver\Logger\Logger;
 use Objects\User;
 use PhpMqtt\Client\MqttClient;
 
 abstract class Request {
 
   protected User $user;
+  protected Logger $logger;
   protected array $params;
   protected string $lastError;
   protected array $result;
@@ -26,6 +27,7 @@ abstract class Request {
 
   public function __construct(User $user, bool $externalCall = false, array $params = array()) {
     $this->user = $user;
+    $this->logger = new Logger($this->getAPIName(), $this->user->getSQL());
     $this->defaultParams = $params;
 
     $this->success = false;
@@ -41,6 +43,19 @@ abstract class Request {
     $this->csrfTokenRequired = true;
   }
 
+  public function getAPIName(): string {
+    if (get_class($this) === Request::class) {
+      return "API";
+    }
+
+    $reflection = new \ReflectionClass($this);
+    if ($reflection->getParentClass()->isAbstract() && $reflection->getParentClass()->isSubclassOf(Request::class)) {
+      return $reflection->getParentClass()->getShortName() . "/" . $reflection->getShortName();
+    } else {
+      return $reflection->getShortName();
+    }
+  }
+
   protected function forbidMethod($method) {
     if (($key = array_search($method, $this->allowedMethods)) !== false) {
       unset($this->allowedMethods[$key]);
@@ -225,6 +240,7 @@ abstract class Request {
       return false;
     }
 
+    $this->success = true;
     $success = $this->_execute();
     if ($this->success !== $success) {
       // _execute returns a different value then it set for $this->success

+ 127 - 98
core/Api/RoutesAPI.class.php

@@ -2,24 +2,20 @@
 
 namespace Api {
 
+  use Api\Routes\GenerateCache;
   use Driver\SQL\Condition\Compare;
+  use Objects\User;
 
   abstract class RoutesAPI extends Request {
 
     const ACTIONS = array("redirect_temporary", "redirect_permanently", "static", "dynamic");
+    const ROUTER_CACHE_CLASS = "\\Cache\\RouterCache";
 
-    protected function formatRegex(string $input, bool $append) : string {
-      $start = startsWith($input, "^");
-      $end = endsWith($input, "$");
-      if ($append) {
-        if (!$start) $input = "^$input";
-        if (!$end) $input = "$input$";
-      } else {
-        if ($start) $input = substr($input, 1);
-        if ($end) $input = substr($input, 0, strlen($input)-1);
-      }
+    protected string $routerCachePath;
 
-      return $input;
+    public function __construct(User $user, bool $externalCall, array $params) {
+      parent::__construct($user, $externalCall, $params);
+      $this->routerCachePath = getClassPath(self::ROUTER_CACHE_CLASS);
     }
 
     protected function routeExists($uid): bool {
@@ -52,6 +48,14 @@ namespace Api {
         ->execute();
 
       $this->lastError = $sql->getLastError();
+      $this->success = $this->success && $this->regenerateCache();
+      return $this->success;
+    }
+
+    protected function regenerateCache(): bool {
+      $req = new GenerateCache($this->user);
+      $this->success = $req->execute();
+      $this->lastError = $req->getLastError();
       return $this->success;
     }
   }
@@ -62,94 +66,45 @@ namespace Api\Routes {
   use Api\Parameter\Parameter;
   use Api\Parameter\StringType;
   use Api\RoutesAPI;
-  use Driver\SQL\Column\Column;
   use Driver\SQL\Condition\Compare;
   use Driver\SQL\Condition\CondBool;
-  use Driver\SQL\Condition\CondRegex;
+  use Objects\Router;
   use Objects\User;
 
   class Fetch extends RoutesAPI {
 
-  public function __construct($user, $externalCall = false) {
-    parent::__construct($user, $externalCall, array());
-  }
-
-  public function _execute(): bool {
-    $sql = $this->user->getSQL();
-
-    $res = $sql
-      ->select("uid", "request", "action", "target", "extra", "active")
-      ->from("Route")
-      ->orderBy("uid")
-      ->ascending()
-      ->execute();
-
-    $this->lastError = $sql->getLastError();
-    $this->success = ($res !== FALSE);
-
-    if ($this->success) {
-      $routes = array();
-      foreach($res as $row) {
-        $routes[] = array(
-          "uid"     => intval($row["uid"]),
-          "request" => $this->formatRegex($row["request"], false),
-          "action"  => $row["action"],
-          "target"  => $row["target"],
-          "extra"   => $row["extra"] ?? "",
-          "active"  => intval($sql->parseBool($row["active"])),
-        );
-      }
-
-      $this->result["routes"] = $routes;
-    }
-
-    return $this->success;
-  }
-}
-
-  class Find extends RoutesAPI {
-
     public function __construct($user, $externalCall = false) {
-      parent::__construct($user, $externalCall, array(
-        'request' => new StringType('request', 128, true, '/')
-      ));
-
-      $this->isPublic = false;
+      parent::__construct($user, $externalCall, array());
     }
 
     public function _execute(): bool {
-      $request = $this->getParam('request');
-      if (!startsWith($request, '/')) {
-        $request = "/$request";
-      }
-
       $sql = $this->user->getSQL();
 
       $res = $sql
-        ->select("uid", "request", "action", "target", "extra")
+        ->select("uid", "request", "action", "target", "extra", "active", "exact")
         ->from("Route")
-        ->where(new CondBool("active"))
-        ->where(new CondRegex($request, new Column("request")))
-        ->orderBy("uid")->ascending()
-        ->limit(1)
+        ->orderBy("uid")
+        ->ascending()
         ->execute();
 
       $this->lastError = $sql->getLastError();
       $this->success = ($res !== FALSE);
 
       if ($this->success) {
-        if (!empty($res)) {
-          $row = $res[0];
-          $this->result["route"] = array(
-            "uid"     => intval($row["uid"]),
+        $routes = array();
+        foreach ($res as $row) {
+          $routes[] = array(
+            "uid" => intval($row["uid"]),
             "request" => $row["request"],
-            "action"  => $row["action"],
-            "target"  => $row["target"],
-            "extra"   => $row["extra"]
+            "action" => $row["action"],
+            "target" => $row["target"],
+            "extra" => $row["extra"] ?? "",
+            "active" => intval($sql->parseBool($row["active"])),
+            "exact" => intval($sql->parseBool($row["exact"])),
           );
-        } else {
-          $this->result["route"] = NULL;
         }
+
+        $this->result["routes"] = $routes;
       }
 
       return $this->success;
@@ -162,7 +117,7 @@ namespace Api\Routes {
 
     public function __construct($user, $externalCall = false) {
       parent::__construct($user, $externalCall, array(
-        'routes' => new Parameter('routes',Parameter::TYPE_ARRAY, false)
+        'routes' => new Parameter('routes', Parameter::TYPE_ARRAY, false)
       ));
     }
 
@@ -179,15 +134,16 @@ namespace Api\Routes {
 
       // INSERT new routes
       if ($this->success) {
-        $stmt = $sql->insert("Route", array("request", "action", "target", "extra", "active"));
+        $stmt = $sql->insert("Route", array("request", "action", "target", "extra", "active", "exact"));
 
-        foreach($this->routes as $route) {
-          $stmt->addRow($route["request"], $route["action"], $route["target"], $route["extra"], $route["active"]);
+        foreach ($this->routes as $route) {
+          $stmt->addRow($route["request"], $route["action"], $route["target"], $route["extra"], $route["active"], $route["exact"]);
         }
         $this->success = ($stmt->execute() !== FALSE);
         $this->lastError = $sql->getLastError();
       }
 
+      $this->success = $this->success && $this->regenerateCache();
       return $this->success;
     }
 
@@ -195,25 +151,37 @@ namespace Api\Routes {
 
       $this->routes = array();
       $keys = array(
-        "request" => Parameter::TYPE_STRING,
+        "request" => [Parameter::TYPE_STRING, Parameter::TYPE_INT],
         "action" => Parameter::TYPE_STRING,
         "target" => Parameter::TYPE_STRING,
-        "extra"  => Parameter::TYPE_STRING,
-        "active" => Parameter::TYPE_BOOLEAN
+        "extra" => Parameter::TYPE_STRING,
+        "active" => Parameter::TYPE_BOOLEAN,
+        "exact" => Parameter::TYPE_BOOLEAN,
       );
 
-      foreach($this->getParam("routes") as $index => $route) {
-        foreach($keys as $key => $expectedType) {
+      foreach ($this->getParam("routes") as $index => $route) {
+        foreach ($keys as $key => $expectedType) {
           if (!array_key_exists($key, $route)) {
             return $this->createError("Route $index missing key: $key");
           }
 
           $value = $route[$key];
           $type = Parameter::parseType($value);
-          if ($type !== $expectedType) {
-            $expectedTypeName = Parameter::names[$expectedType];
+          if (!is_array($expectedType)) {
+            $expectedType = [$expectedType];
+          }
+
+          if (!in_array($type, $expectedType)) {
+            if (count($expectedType) > 0) {
+              $expectedTypeName = "expected: " . Parameter::names[$expectedType];
+            } else {
+              $expectedTypeName = "expected one of: " . implode(",", array_map(
+                function ($type) {
+                  return Parameter::names[$type];
+                }, $expectedType));
+            }
             $gotTypeName = Parameter::names[$type];
-            return $this->createError("Route $index has invalid value for key: $key, expected: $expectedTypeName, got: $gotTypeName");
+            return $this->createError("Route $index has invalid value for key: $key, $expectedTypeName, got: $gotTypeName");
           }
         }
 
@@ -222,16 +190,14 @@ namespace Api\Routes {
           return $this->createError("Invalid action: $action");
         }
 
-        if(empty($route["request"])) {
+        if (empty($route["request"])) {
           return $this->createError("Request cannot be empty.");
         }
 
-        if(empty($route["target"])) {
+        if (empty($route["target"])) {
           return $this->createError("Target cannot be empty.");
         }
 
-        // add start- and end pattern for database queries
-        $route["request"] = $this->formatRegex($route["request"], true);
         $this->routes[] = $route;
       }
 
@@ -246,14 +212,14 @@ namespace Api\Routes {
         "request" => new StringType("request", 128),
         "action" => new StringType("action"),
         "target" => new StringType("target", 128),
-        "extra"  => new StringType("extra", 64, true, ""),
+        "extra" => new StringType("extra", 64, true, ""),
       ));
       $this->isPublic = false;
     }
 
     public function _execute(): bool {
 
-      $request = $this->formatRegex($this->getParam("request"), true);
+      $request = $this->getParam("request");
       $action = $this->getParam("action");
       $target = $this->getParam("target");
       $extra = $this->getParam("extra");
@@ -268,6 +234,7 @@ namespace Api\Routes {
         ->execute();
 
       $this->lastError = $sql->getLastError();
+      $this->success = $this->success && $this->regenerateCache();
       return $this->success;
     }
   }
@@ -279,7 +246,7 @@ namespace Api\Routes {
         "request" => new StringType("request", 128),
         "action" => new StringType("action"),
         "target" => new StringType("target", 128),
-        "extra"  => new StringType("extra", 64, true, ""),
+        "extra" => new StringType("extra", 64, true, ""),
       ));
       $this->isPublic = false;
     }
@@ -291,7 +258,7 @@ namespace Api\Routes {
         return false;
       }
 
-      $request = $this->formatRegex($this->getParam("request"), true);
+      $request = $this->getParam("request");
       $action = $this->getParam("action");
       $target = $this->getParam("target");
       $extra = $this->getParam("extra");
@@ -309,6 +276,7 @@ namespace Api\Routes {
         ->execute();
 
       $this->lastError = $sql->getLastError();
+      $this->success = $this->success && $this->regenerateCache();
       return $this->success;
     }
   }
@@ -334,6 +302,7 @@ namespace Api\Routes {
         ->execute();
 
       $this->lastError = $sql->getLastError();
+      $this->success = $this->success && $this->regenerateCache();
       return $this->success;
     }
   }
@@ -347,7 +316,6 @@ namespace Api\Routes {
     }
 
     public function _execute(): bool {
-
       $uid = $this->getParam("uid");
       return $this->toggleRoute($uid, true);
     }
@@ -362,10 +330,71 @@ namespace Api\Routes {
     }
 
     public function _execute(): bool {
-
       $uid = $this->getParam("uid");
       return $this->toggleRoute($uid, false);
     }
   }
+
+  class GenerateCache extends RoutesAPI {
+
+    private ?Router $router;
+
+    public function __construct(User $user, bool $externalCall = false) {
+      parent::__construct($user, $externalCall, []);
+      $this->isPublic = false;
+      $this->router = null;
+    }
+
+    protected function _execute(): bool {
+      $sql = $this->user->getSQL();
+      $res = $sql
+        ->select("uid", "request", "action", "target", "extra", "exact")
+        ->from("Route")
+        ->where(new CondBool("active"))
+        ->orderBy("uid")->ascending()
+        ->execute();
+
+      $this->success = $res !== false;
+      $this->lastError = $sql->getLastError();
+      if (!$this->success) {
+        return false;
+      }
+
+      $this->router = new Router($this->user);
+      foreach ($res as $row) {
+        $request = $row["request"];
+        $target = $row["target"];
+        $exact = $sql->parseBool($row["exact"]);
+        switch ($row["action"]) {
+          case "redirect_temporary":
+            $this->router->addRoute(new Router\RedirectRoute($request, $exact, $target, 307));
+            break;
+          case "redirect_permanently":
+            $this->router->addRoute(new Router\RedirectRoute($request, $exact, $target, 308));
+            break;
+          case "static":
+            $this->router->addRoute(new Router\StaticFileRoute($request, $exact, $target));
+            break;
+          case "dynamic":
+            $extra = json_decode($row["extra"]) ?? [];
+            $this->router->addRoute(new Router\DocumentRoute($request, $exact, $target, ...$extra));
+            break;
+          default:
+            break;
+        }
+      }
+
+      $this->success = $this->router->writeCache($this->routerCachePath);
+      if (!$this->success) {
+        return $this->createError("Error saving router cache file: " . $this->routerCachePath);
+      }
+
+      return $this->success;
+    }
+
+    public function getRouter(): ?Router {
+      return $this->router;
+    }
+  }
 }
 

+ 1 - 1
core/Api/TemplateAPI.class.php

@@ -46,7 +46,7 @@ namespace Api\Template {
       }
 
       $templateDir = WEBROOT . "/core/Templates/";
-      $templateCache = WEBROOT . "/core/TemplateCache/";
+      $templateCache = WEBROOT . "/core/Cache/Templates/";
       $path = realpath($templateDir . $templateFile);
       if (!startsWith($path, realpath($templateDir))) {
         return $this->createError("Template file not in template directory");

+ 12 - 4
core/Api/UserAPI.class.php

@@ -525,11 +525,13 @@ namespace Api\User {
         }
 
         if (!$this->success) {
+          $this->logger->error("Could not deliver email to=$email type=invite reason=" . $this->lastError);
           $this->lastError = "The invitation was created but the confirmation email could not be sent. " .
-            "Please contact the server administration. Reason: " . $this->lastError;
+            "Please contact the server administration. This issue has been automatically logged. Reason: " . $this->lastError;
         }
       }
 
+      $this->logger->info("Created new user with uid=$id");
       return $this->success;
     }
   }
@@ -844,17 +846,20 @@ namespace Api\User {
           $this->success = $request->execute(array(
             "to" => $email,
             "subject" => "[$siteName] E-Mail Confirmation",
-            "body" => $messageBody
+            "body" => $messageBody,
+            "async" => true,
           ));
           $this->lastError = $request->getLastError();
         }
       }
 
       if (!$this->success) {
+        $this->logger->error("Could not deliver email to=$email type=register reason=" . $this->lastError);
         $this->lastError = "Your account was registered but the confirmation email could not be sent. " .
-          "Please contact the server administration. Reason: " . $this->lastError;
+          "Please contact the server administration. This issue has been automatically logged. Reason: " . $this->lastError;
       }
 
+      $this->logger->info("Registered new user with uid=" . $this->userId);
       return $this->success;
     }
   }
@@ -1124,6 +1129,7 @@ namespace Api\User {
             "gpgFingerprint" => $gpgFingerprint
           ));
           $this->lastError = $request->getLastError();
+          $this->logger->info("Requested password reset for user uid=" . $user["uid"] . " by ip_address=" . $_SERVER["REMOTE_ADDR"]);
         }
       }
 
@@ -1304,6 +1310,7 @@ namespace Api\User {
       } else if (!$this->updateUser($result["user"]["uid"], $password)) {
         return false;
       } else {
+        $this->logger->info("Issued password reset for user uid=" . $result["user"]["uid"]);
         $this->invalidateToken($token);
         return true;
       }
@@ -1475,11 +1482,12 @@ namespace Api\User {
       $settings = $this->user->getConfiguration()->getSettings();
       $baseUrl = htmlspecialchars($settings->getBaseUrl());
       $token = htmlspecialchars(urlencode($token));
+      $url = "$baseUrl/settings?confirmGPG&token=$token";
       $mailBody = "Hello $name,<br><br>" .
         "you imported a GPG public key for end-to-end encrypted mail communication. " .
         "To confirm the key and verify, you own the corresponding private key, please click on the following link. " .
         "The link is active for one hour.<br><br>" .
-        "<a href='$baseUrl/confirmGPG?token=$token'>$baseUrl/settings?confirmGPG&token=$token</a><br>
+        "<a href='$url'>$url</a><br>
         Best Regards<br>
         ilum:e Security Lab";
 

+ 2 - 0
core/Cache/.gitignore

@@ -0,0 +1,2 @@
+*
+!.gitkeep

+ 0 - 0
core/TemplateCache/.gitkeep → core/Cache/.gitkeep


+ 15 - 11
core/Configuration/Configuration.class.php

@@ -31,12 +31,12 @@ class Configuration {
     return $this->settings;
   }
 
-  public function create(string $className, $data) {
+  public static function create(string $className, $data) {
     $path = getClassPath("\\Configuration\\$className");
 
     if ($data) {
       if (is_string($data)) {
-        $key = addslashes($data);
+        $key = var_export($data, true);
         $code = intendCode(
           "<?php
 
@@ -45,23 +45,23 @@ class Configuration {
           class $className extends KeyData {
           
             public function __construct() {
-              parent::__construct('$key');
+              parent::__construct($key);
             }
             
           }", false
         );
       } else if ($data instanceof ConnectionData) {
         $superClass = get_class($data);
-        $host = addslashes($data->getHost());
-        $port = $data->getPort();
-        $login = addslashes($data->getLogin());
-        $password = addslashes($data->getPassword());
+        $host = var_export($data->getHost(), true);
+        $port = var_export($data->getPort(), true);
+        $login = var_export($data->getLogin(), true);
+        $password = var_export($data->getPassword(), true);
 
         $properties = "";
         foreach ($data->getProperties() as $key => $val) {
-          $key = addslashes($key);
-          $val = is_string($val) ? "'" . addslashes($val) . "'" : $val;
-          $properties .= "\n\$this->setProperty('$key', $val);";
+          $key = var_export($key, true);
+          $val = var_export($val, true);
+          $properties .= "\n\$this->setProperty($key, $val);";
         }
 
         $code = intendCode(
@@ -72,7 +72,7 @@ class Configuration {
           class $className extends \\$superClass {
 
             public function __construct() {
-              parent::__construct('$host', $port, '$login', '$password');$properties
+              parent::__construct($host, $port, $login, $password);$properties
             }
           }", false
         );
@@ -94,4 +94,8 @@ class Configuration {
 
     return true;
   }
+
+  public function setDatabase(ConnectionData $connectionData) {
+    $this->database = $connectionData;
+  }
 }

+ 10 - 9
core/Configuration/CreateDatabase.class.php

@@ -151,18 +151,19 @@ class CreateDatabase extends DatabaseScript {
       ->addString("target", 128)
       ->addString("extra", 64, true)
       ->addBool("active", true)
+      ->addBool("exact", true)
       ->primaryKey("uid")
       ->unique("request");
 
-    $queries[] = $sql->insert("Route", array("request", "action", "target", "extra"))
-      ->addRow("^/admin(/.*)?$", "dynamic", "\\Documents\\Admin", NULL)
-      ->addRow("^/register/?$", "dynamic", "\\Documents\\Account", "account/register.twig")
-      ->addRow("^/confirmEmail/?$", "dynamic", "\\Documents\\Account", "account/confirm_email.twig")
-      ->addRow("^/acceptInvite/?$", "dynamic", "\\Documents\\Account", "account/accept_invite.twig")
-      ->addRow("^/resetPassword/?$", "dynamic", "\\Documents\\Account", "account/reset_password.twig")
-      ->addRow("^/login/?$", "dynamic", "\\Documents\\Account", "account/login.twig")
-      ->addRow("^/resendConfirmEmail/?$", "dynamic", "\\Documents\\Account", "account/resend_confirm_email.twig")
-      ->addRow("^/$", "static", "/static/welcome.html", NULL);
+    $queries[] = $sql->insert("Route", ["request", "action", "target", "extra", "exact"])
+      ->addRow("/admin", "dynamic", "\\Documents\\Admin", NULL, false)
+      ->addRow("/register", "dynamic", "\\Documents\\Account", json_encode(["account/register.twig"]), true)
+      ->addRow("/confirmEmail", "dynamic", "\\Documents\\Account", json_encode(["account/confirm_email.twig"]), true)
+      ->addRow("/acceptInvite", "dynamic", "\\Documents\\Account", json_encode(["account/accept_invite.twig"]), true)
+      ->addRow("/resetPassword", "dynamic", "\\Documents\\Account", json_encode(["account/reset_password.twig"]), true)
+      ->addRow("/login", "dynamic", "\\Documents\\Account", json_encode(["account/login.twig"]), true)
+      ->addRow("/resendConfirmEmail", "dynamic", "\\Documents\\Account", json_encode(["account/resend_confirm_email.twig"]), true)
+      ->addRow("/", "static", "/static/welcome.html", NULL, true);
 
     $queries[] = $sql->createTable("Settings")
       ->addString("name", 32)

+ 24 - 0
core/Configuration/Patch/SystemLog_2022_03_30.class.php

@@ -0,0 +1,24 @@
+<?php
+
+namespace Configuration\Patch;
+
+use Configuration\DatabaseScript;
+use Driver\SQL\SQL;
+
+class SystemLog_2022_03_30 extends DatabaseScript {
+
+  public static function createQueries(SQL $sql): array {
+    return [
+      $sql->createTable("SystemLog")
+        ->onlyIfNotExists()
+        ->addSerial("id")
+        ->addDateTime("timestamp", false, $sql->now())
+        ->addString("message")
+        ->addString("module", 64, false, "global")
+        ->addEnum("severity", ["debug", "info", "warning", "error", "severe"])
+        ->primaryKey("id"),
+      $sql->insert("ApiPermission", ["method", "groups", "description"])
+        ->addRow("Logs/get", [USER_GROUP_ADMIN], "Allows users to fetch system logs")
+    ];
+  }
+}

+ 2 - 2
core/Documents/Account.class.php

@@ -8,8 +8,8 @@ use Objects\User;
 
 
 class Account extends TemplateDocument {
-  public function __construct(User $user, ?string $template) {
-    parent::__construct($user, $template);
+  public function __construct(User $user, string $templateName) {
+    parent::__construct($user, $templateName);
     $this->enableCSP();
   }
 

+ 0 - 18
core/Documents/Document404.class.php

@@ -1,18 +0,0 @@
-<?php
-
-namespace Documents;
-
-use Elements\TemplateDocument;
-use Objects\User;
-
-class Document404 extends TemplateDocument {
-
-  public function __construct(User $user) {
-    parent::__construct($user, "404.twig");
-  }
-
-  public function loadParameters() {
-    parent::loadParameters();
-    http_response_code(404);
-  }
-}

+ 18 - 4
core/Documents/Install.class.php

@@ -16,6 +16,7 @@ namespace Documents {
 
 namespace Documents\Install {
 
+  use Configuration\Configuration;
   use Configuration\CreateDatabase;
   use Driver\SQL\Query\Commit;
   use Driver\SQL\Query\RollBack;
@@ -233,7 +234,7 @@ namespace Documents\Install {
           $username = posix_getpwuid($userId)['name'];
           $failedRequirements[] = sprintf("<b>%s</b> is not owned by current user: $username ($userId). " .
               "Try running <b>chown -R $userId %s</b> or give the required directories write permissions: " .
-              "<b>core/Configuration</b>, <b>core/TemplateCache</b>, <b>core/External</b>",
+              "<b>core/Configuration</b>, <b>core/Cache</b>, <b>core/External</b>",
             WEBROOT, WEBROOT);
           $success = false;
         }
@@ -363,10 +364,23 @@ namespace Documents\Install {
             }
           }
 
-          $config = $this->getDocument()->getUser()->getConfiguration();
-          if (!$config->create("Database", $connectionData)) {
+          $user = $this->getDocument()->getUser();
+          $config = $user->getConfiguration();
+          if (Configuration::create("Database", $connectionData) === false) {
             $success = false;
-            $msg = "Unable to write file";
+            $msg = "Unable to write database file";
+          } else {
+            $config->setDatabase($connectionData);
+            if (!$user->connectDB()) {
+              $success = false;
+              $msg = "Unable to verify database connection after installation";
+            } else {
+              $req = new \Api\Routes\GenerateCache($user);
+              if (!$req->execute()) {
+                $success = false;
+                $msg = "Unable to write route file: " . $req->getLastError();
+              }
+            }
           }
 
           $sql->close();

+ 94 - 0
core/Driver/Logger/Logger.class.php

@@ -0,0 +1,94 @@
+<?php
+
+namespace Driver\Logger;
+
+use Driver\SQL\SQL;
+
+class Logger {
+
+  public const LOG_FILE_DATE_FORMAT = "Y-m-d_H-i-s_v";
+  public const LOG_LEVELS = [
+    0 => "debug",
+    1 => "info",
+    2 => "warning",
+    3 => "error",
+    4 => "severe"
+  ];
+
+  public static Logger $INSTANCE;
+
+  private ?SQL $sql;
+  private string $module;
+
+  public function __construct(string $module = "Unknown", ?SQL $sql = null) {
+    $this->module = $module;
+    $this->sql = $sql;
+  }
+
+  protected function getStackTrace(int $pop = 2): string {
+    $debugTrace = debug_backtrace();
+    if ($pop > 0) {
+      array_splice($debugTrace, 0, $pop);
+    }
+
+    return implode("\n", array_map(function ($trace) {
+      return $trace["file"] . "#" . $trace["line"] . ": " . $trace["function"] . "()";
+    }, $debugTrace));
+  }
+
+  public function log(string $message, string $severity, bool $appendStackTrace = true) {
+
+    if ($appendStackTrace) {
+      $message .= "\n" . $this->getStackTrace();
+    }
+
+    if ($this->sql !== null && $this->sql->isConnected()) {
+      $success = $this->sql->insert("SystemLog", ["module", "message", "severity"])
+        ->addRow($this->module, $message, $severity)
+        ->execute();
+      if ($success !== false) {
+        return;
+      }
+    }
+
+    // database logging failed, try to log to file
+    $module = preg_replace("/[^a-zA-Z0-9-]/", "-", $this->module);
+    $date = (\DateTime::createFromFormat('U.u', microtime(true)))->format(self::LOG_FILE_DATE_FORMAT);
+    $logFile = implode("_", [$module, $severity, $date]) . ".log";
+    $logPath = implode(DIRECTORY_SEPARATOR, [WEBROOT, "core", "Logs", $logFile]);
+    @file_put_contents($logPath, $message);
+  }
+
+  public function error(string $message): string {
+    $this->log($message, "error");
+    return $message;
+  }
+
+  public function severe(string $message): string {
+    $this->log($message, "severe");
+    return $message;
+  }
+
+  public function warning(string $message): string {
+    $this->log($message, "warning", false);
+    return $message;
+  }
+
+  public function info(string $message): string {
+    $this->log($message, "info", false);
+    return $message;
+  }
+
+  public function debug(string $message): string {
+    $this->log($message, "debug");
+    return $message;
+  }
+
+  public static function instance(): Logger {
+    if (self::$INSTANCE === null) {
+      self::$INSTANCE = new Logger("Global");
+    }
+
+    return self::$INSTANCE;
+  }
+}

+ 19 - 19
core/Driver/SQL/MySQL.class.php

@@ -59,7 +59,7 @@ class MySQL extends SQL {
     );
 
     if (mysqli_connect_errno()) {
-      $this->lastError = "Failed to connect to MySQL: " . mysqli_connect_error();
+      $this->lastError = $this->logger->severe("Failed to connect to MySQL: " . mysqli_connect_error());
       $this->connection = NULL;
       return false;
     }
@@ -164,20 +164,20 @@ class MySQL extends SQL {
                 }
                 $success = true;
               } else {
-                $this->lastError = "PreparedStatement::get_result failed: $stmt->error ($stmt->errno)";
+                $this->lastError = $this->logger->error("PreparedStatement::get_result failed: $stmt->error ($stmt->errno)");
               }
             } else {
               $success = true;
             }
           } else {
-            $this->lastError = "PreparedStatement::execute failed: $stmt->error ($stmt->errno)";
+            $this->lastError = $this->logger->error("PreparedStatement::execute failed: $stmt->error ($stmt->errno)");
           }
         } else {
-          $this->lastError = "PreparedStatement::prepare failed: $stmt->error ($stmt->errno)";
+          $this->lastError = $this->logger->error("PreparedStatement::prepare failed: $stmt->error ($stmt->errno)");
         }
       }
     } catch (\mysqli_sql_exception $exception) {
-      $this->lastError = "MySQL::execute failed: $stmt->error ($stmt->errno)";
+      $this->lastError = $this->logger->error("MySQL::execute failed: $stmt->error ($stmt->errno)");
     } finally {
       if ($res !== null && !is_bool($res)) {
         $res->close();
@@ -214,7 +214,7 @@ class MySQL extends SQL {
       return " ON DUPLICATE KEY UPDATE " . implode(",", $updateValues);
     } else {
       $strategyClass = get_class($strategy);
-      $this->lastError = "ON DUPLICATE Strategy $strategyClass is not supported yet.";
+      $this->lastError = $this->logger->error("ON DUPLICATE Strategy $strategyClass is not supported yet.");
       return null;
     }
   }
@@ -243,7 +243,7 @@ class MySQL extends SQL {
     } else if($column instanceof JsonColumn) {
       return "LONGTEXT"; # some maria db setups don't allow JSON here…
     } else {
-      $this->lastError = "Unsupported Column Type: " . get_class($column);
+      $this->lastError = $this->logger->error("Unsupported Column Type: " . get_class($column));
       return NULL;
     }
   }
@@ -251,17 +251,17 @@ class MySQL extends SQL {
   public function getColumnDefinition(Column $column): ?string {
     $columnName = $this->columnName($column->getName());
     $defaultValue = $column->getDefaultValue();
-    $type = $this->getColumnType($column);
-    if (!$type) {
-      if ($column instanceof EnumColumn) {
-        $values = array();
-        foreach($column->getValues() as $value) {
-          $values[] = $this->getValueDefinition($value);
-        }
+    if ($column instanceof EnumColumn) { // check this, shouldn't it be in getColumnType?
+      $values = array();
+      foreach($column->getValues() as $value) {
+        $values[] = $this->getValueDefinition($value);
+      }
 
-        $values = implode(",", $values);
-        $type = "ENUM($values)";
-      } else {
+      $values = implode(",", $values);
+      $type = "ENUM($values)";
+    } else {
+      $type = $this->getColumnType($column);
+      if (!$type) {
         return null;
       }
     }
@@ -393,7 +393,7 @@ class MySQL extends SQL {
       if ($param instanceof Column) {
         $paramDefs[] = $this->getParameterDefinition($param);
       } else {
-        $this->setLastError("PROCEDURE parameter type " . gettype($returns) . "  is not implemented yet");
+        $this->lastError = $this->logger->error("PROCEDURE parameter type " . gettype($returns) . "  is not implemented yet");
         return null;
       }
     }
@@ -402,7 +402,7 @@ class MySQL extends SQL {
       if ($returns instanceof Column) {
         $paramDefs[] = $this->getParameterDefinition($returns, true);
       } else if (!($returns instanceof Trigger)) { // mysql does not need to return triggers here
-        $this->setLastError("PROCEDURE RETURN type " . gettype($returns) . "  is not implemented yet");
+        $this->lastError = $this->logger->error("PROCEDURE RETURN type " . gettype($returns) . "  is not implemented yet");
         return null;
       }
     }

+ 4 - 5
core/Driver/SQL/PostgreSQL.class.php

@@ -67,7 +67,7 @@ class PostgreSQL extends SQL {
 
     $this->connection = @pg_connect(implode(" ", $connectionString), PGSQL_CONNECT_FORCE_NEW);
     if (!$this->connection) {
-      $this->lastError = "Failed to connect to Database";
+      $this->lastError = $this->logger->severe("Failed to connect to Database");
       $this->connection = NULL;
       return false;
     }
@@ -170,7 +170,7 @@ class PostgreSQL extends SQL {
           return " ON CONFLICT ($conflictingColumns) DO UPDATE SET $updateValues";
         } else {
           $strategyClass = get_class($strategy);
-          $this->lastError = "ON DUPLICATE Strategy $strategyClass is not supported yet.";
+          $this->lastError = $this->logger->error("ON DUPLICATE Strategy $strategyClass is not supported yet.");
           return null;
         }
       } else {
@@ -233,7 +233,7 @@ class PostgreSQL extends SQL {
     } else if($column instanceof JsonColumn) {
       return "JSON";
     } else {
-      $this->lastError = "Unsupported Column Type: " . get_class($column);
+      $this->lastError = $this->logger->error("Unsupported Column Type: " . get_class($column));
       return NULL;
     }
   }
@@ -317,8 +317,7 @@ class PostgreSQL extends SQL {
     if ($col instanceof KeyWord) {
       return $col->getValue();
     } elseif(is_array($col)) {
-      $columns = array();
-      foreach($col as $c) $columns[] = $this->columnName($c);
+      $columns = array_map(function ($c) { return $this->columnName($c); }, $col);
       return implode(",", $columns);
     } else {
       if (($index = strrpos($col, ".")) !== FALSE) {

+ 9 - 5
core/Driver/SQL/SQL.class.php

@@ -2,6 +2,7 @@
 
 namespace Driver\SQL;
 
+use Driver\Logger\Logger;
 use Driver\SQL\Column\Column;
 use Driver\SQL\Condition\Compare;
 use Driver\SQL\Condition\CondAnd;
@@ -40,6 +41,7 @@ use Objects\ConnectionData;
 
 abstract class SQL {
 
+  protected Logger $logger;
   protected string $lastError;
   protected $connection;
   protected ConnectionData $connectionData;
@@ -50,6 +52,7 @@ abstract class SQL {
     $this->lastError = 'Unknown Error';
     $this->connectionData = $connectionData;
     $this->lastInsertId = 0;
+    $this->logger = new Logger(getClassName($this), $this);
   }
 
   public function isConnected(): bool {
@@ -168,7 +171,7 @@ abstract class SQL {
 
       return $code;
     } else {
-      $this->lastError = "Unsupported constraint type: " . get_class($constraint);
+      $this->lastError = $this->logger->error("Unsupported constraint type: " . get_class($constraint));
       return null;
     }
   }
@@ -200,7 +203,7 @@ abstract class SQL {
     } else if ($value === null) {
       return "NULL";
     } else {
-      $this->lastError = "Cannot create unsafe value of type: " . gettype($value);
+      $this->lastError = $this->logger->error("Cannot create unsafe value of type: " . gettype($value));
       return null;
     }
   }
@@ -290,7 +293,7 @@ abstract class SQL {
       } else if($haystack instanceof Select) {
         $values = $haystack->build($params);
       } else {
-        $this->lastError = "Unsupported in-expression value: " . get_class($condition);
+        $this->lastError = $this->logger->error("Unsupported in-expression value: " . get_class($condition));
         return false;
       }
 
@@ -322,7 +325,7 @@ abstract class SQL {
     } else if ($condition instanceof Exists) {
         return "EXISTS(" .$condition->getSubQuery()->build($params) . ")";
     } else {
-      $this->lastError = "Unsupported condition type: " . gettype($condition);
+      $this->lastError = $this->logger->error("Unsupported condition type: " . gettype($condition));
       return null;
     }
   }
@@ -345,7 +348,7 @@ abstract class SQL {
       $alias = $this->columnName($exp->getAlias());
       return "SUM($value) AS $alias";
     } else {
-      $this->lastError = "Unsupported expression type: " . get_class($exp);
+      $this->lastError = $this->logger->error("Unsupported expression type: " . get_class($exp));
       return null;
     }
   }
@@ -370,6 +373,7 @@ abstract class SQL {
     } else if ($type === "postgres") {
       $sql = new PostgreSQL($connectionData);
     } else {
+      Logger::instance()->error("Unknown database type: $type");
       return "Unknown database type";
     }
 

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

@@ -48,7 +48,7 @@ abstract class Document {
     $this->cspWhitelist[] = $this->domain . $path;
   }
 
-  public function getCode(): string {
+  public function getCode(array $params = []): string {
     if ($this->databaseRequired) {
       $sql = $this->user->getSQL();
       if (is_null($sql)) {

+ 5 - 5
core/Elements/TemplateDocument.class.php

@@ -17,14 +17,14 @@ class TemplateDocument extends Document {
   private FilesystemLoader $twigLoader;
   protected string $title;
 
-  public function __construct(User $user, string $templateName, array $initialParameters = []) {
+  public function __construct(User $user, string $templateName, array $params = []) {
     parent::__construct($user);
     $this->title = "";
     $this->templateName = $templateName;
-    $this->parameters = $initialParameters;
+    $this->parameters = $params;
     $this->twigLoader = new FilesystemLoader(WEBROOT . '/core/Templates');
     $this->twigEnvironment = new Environment($this->twigLoader, [
-      'cache' => WEBROOT . '/core/TemplateCache',
+      'cache' => WEBROOT . '/core/Cache/Templates/',
       'auto_reload' => true
     ]);
   }
@@ -37,8 +37,8 @@ class TemplateDocument extends Document {
 
   }
 
-  public function getCode(): string {
-    parent::getCode();
+  public function getCode(array $params = []): string {
+    parent::getCode($params);
     $this->loadParameters();
     return $this->renderTemplate($this->templateName, $this->parameters);
   }

+ 2 - 1
core/External/.gitignore

@@ -1 +1,2 @@
-vendor/
+vendor/
+cache/

+ 390 - 0
core/Objects/Router.class.php

@@ -0,0 +1,390 @@
+<?php
+
+namespace Objects {
+
+  use Driver\Logger\Logger;
+  use Objects\Router\AbstractRoute;
+
+  class Router {
+
+    private User $user;
+    private Logger $logger;
+    protected array $routes;
+    protected array $statusCodeRoutes;
+
+    public function __construct(User $user) {
+      $this->user = $user;
+      $this->logger = new Logger("Router", $user->getSQL());
+      $this->routes = [];
+      $this->statusCodeRoutes = [];
+    }
+
+    public function run(string $url): string {
+
+      // TODO: do we want a global try cache and return status page 500 on any error?
+      // or do we want to have a global status page function here?
+
+      $url = strtok($url, "?");
+      foreach ($this->routes as $route) {
+        $pathParams = $route->match($url);
+        if ($pathParams !== false) {
+          return $route->call($this, $pathParams);
+        }
+      }
+
+      return $this->returnStatusCode(404);
+    }
+
+    public function returnStatusCode(int $code, array $params = []): string {
+      http_response_code($code);
+      $params["status_code"] = $code;
+      $params["status_description"] = HTTP_STATUS_DESCRIPTIONS[$code] ?? "Unknown Error";
+      $route = $this->statusCodeRoutes[strval($code)] ?? null;
+      if ($route) {
+        return $route->call($this, $params);
+      } else {
+        $req = new \Api\Template\Render($this->user);
+        $res = $req->execute(["file" => "error_document.twig", "parameters" => $params]);
+        if ($res) {
+          return $req->getResult()["html"];
+        } else {
+          var_dump($req->getLastError());
+          $description = htmlspecialchars($params["status_description"]);
+          return "<b>$code - $description</b>";
+        }
+      }
+    }
+
+    public function addRoute(AbstractRoute $route) {
+      if (preg_match("/^\d+$/", $route->getPattern())) {
+        $this->statusCodeRoutes[$route->getPattern()] = $route;
+      } else {
+        $this->routes[] = $route;
+      }
+    }
+
+    public function writeCache(string $file): bool {
+
+      $routes = "";
+      foreach ($this->routes as $route) {
+        $constructor = $route->generateCache();
+        $routes .= "\n    \$this->addRoute($constructor);";
+      }
+
+      $date = (new \DateTime())->format("Y/m/d H:i:s");
+      $code = "<?php
+
+/**
+ * DO NOT EDIT! 
+ * This file is automatically generated by the RoutesAPI on $date.
+ */
+
+namespace Cache;
+use Objects\User;
+use Objects\Router;
+
+class RouterCache extends Router {
+
+  public function __construct(User \$user) {
+    parent::__construct(\$user);$routes
+  }
+}
+";
+
+      if (@file_put_contents($file, $code) === false) {
+        $this->logger->severe("Could not write Router cache file: $file");
+        return false;
+      }
+
+      return true;
+    }
+
+    public function getUser(): User {
+      return $this->user;
+    }
+
+    public function getLogger(): Logger {
+      return $this->logger;
+    }
+
+    public static function cleanURL(string $url, bool $cleanGET = true): string {
+      // strip GET parameters
+      if ($cleanGET) {
+        if (($index = strpos($url, "?")) !== false) {
+          $url = substr($url, 0, $index);
+        }
+      }
+
+      // strip document reference part
+      if (($index = strpos($url, "#")) !== false) {
+        $url = substr($url, 0, $index);
+      }
+
+      // strip leading slash
+      return preg_replace("/^\/+/", "", $url);
+    }
+  }
+}
+
+namespace Objects\Router {
+
+  use Api\Parameter\Parameter;
+  use Elements\Document;
+  use Objects\Router;
+  use PHPUnit\TextUI\ReflectionException;
+
+  abstract class AbstractRoute {
+
+    private string $pattern;
+    private bool $exact;
+
+    public function __construct(string $pattern, bool $exact = true) {
+      $this->pattern = $pattern;
+      $this->exact = $exact;
+    }
+
+    private static function parseParamType(?string $type): ?int {
+      if ($type === null || trim($type) === "") {
+        return null;
+      }
+
+      $type = strtolower(trim($type));
+      if (in_array($type, ["int", "integer"])) {
+        return Parameter::TYPE_INT;
+      } else if (in_array($type, ["float", "double"])) {
+        return Parameter::TYPE_FLOAT;
+      } else if (in_array($type, ["bool", "boolean"])) {
+        return Parameter::TYPE_BOOLEAN;
+      } else {
+        return Parameter::TYPE_STRING;
+      }
+    }
+
+    public function getPattern(): string {
+      return $this->pattern;
+    }
+
+    public abstract function call(Router $router, array $params): string;
+
+    protected function getArgs(): array {
+      return [$this->pattern, $this->exact];
+    }
+
+    public function generateCache(): string {
+      $reflection = new \ReflectionClass($this);
+      $className = $reflection->getName();
+      $args = implode(", ", array_map(function ($arg) {
+          return var_export($arg, true);
+        }, $this->getArgs()));
+      return "new \\$className($args)";
+    }
+    
+    public function match(string $url) {
+
+      # /test/{abc}/{param:?}/{xyz:int}/{aaa:int?}
+      $patternParts = explode("/", Router::cleanURL($this->pattern, false));
+      $countPattern = count($patternParts);
+      $patternOffset = 0;
+
+      # /test/param/optional/123
+      $urlParts = explode("/", $url);
+      $countUrl = count($urlParts);
+      $urlOffset = 0;
+
+      $params = [];
+      for (; $patternOffset < $countPattern; $patternOffset++) {
+
+        if (!preg_match("/^{.*}$/", $patternParts[$patternOffset])) {
+
+          // not a parameter? check if it matches
+          if ($urlOffset >= $countUrl || $urlParts[$urlOffset] !== $patternParts[$patternOffset]) {
+            return false;
+          }
+
+          $urlOffset++;
+
+        } else {
+
+          // we got a parameter here
+          $paramDefinition = explode(":", substr($patternParts[$patternOffset], 1, -1));
+          $paramName = array_shift($paramDefinition);
+          $paramType = array_shift($paramDefinition);
+          $paramOptional = endsWith($paramType, "?");
+          if ($paramOptional) {
+            $paramType = substr($paramType, 0, -1);
+          }
+
+          $paramType = self::parseParamType($paramType);
+          if ($urlOffset >= $countUrl || $urlParts[$urlOffset] === "") {
+            if ($paramOptional) {
+              $param = $urlParts[$urlOffset] ?? null;
+              if ($param !== null && $paramType !== null && Parameter::parseType($param) !== $paramType) {
+                return false;
+              }
+
+              $params[$paramName] = $param;
+              if ($urlOffset < $countUrl) {
+                $urlOffset++;
+              }
+            } else {
+              return false;
+            }
+          } else {
+            $param = $urlParts[$urlOffset];
+            if ($paramType !== null && Parameter::parseType($param) !== $paramType) {
+              return false;
+            }
+
+            $params[$paramName] = $param;
+            $urlOffset++;
+          }
+        }
+      }
+
+      if ($urlOffset !== $countUrl && $this->exact) {
+        return false;
+      }
+
+      return $params;
+    }
+  }
+
+  class EmptyRoute extends AbstractRoute {
+
+    public function __construct(string $pattern, bool $exact = true) {
+      parent::__construct($pattern, $exact);
+    }
+
+    public function call(Router $router, array $params): string {
+      return "";
+    }
+  }
+
+  class StaticFileRoute extends AbstractRoute {
+
+    private string $path;
+    private int $code;
+
+    public function __construct(string $pattern, bool $exact, string $path, int $code = 200) {
+      parent::__construct($pattern, $exact);
+      $this->path = $path;
+      $this->code = $code;
+    }
+
+    public function call(Router $router, array $params): string {
+      http_response_code($this->code);
+      return serveStatic(WEBROOT, $this->path);
+    }
+
+    protected function getArgs(): array {
+      return array_merge(parent::getArgs(), [$this->path, $this->code]);
+    }
+  }
+
+  class StaticRoute extends AbstractRoute {
+
+    private string $data;
+    private int $code;
+
+    public function __construct(string $pattern, bool $exact, string $data, int $code = 200) {
+      parent::__construct($pattern, $exact);
+      $this->data = $data;
+      $this->code = $code;
+    }
+
+    public function call(Router $router, array $params): string {
+      http_response_code($this->code);
+      return $this->data;
+    }
+
+    protected function getArgs(): array {
+      return array_merge(parent::getArgs(), [$this->data, $this->code]);
+    }
+  }
+
+  class RedirectRoute extends AbstractRoute {
+
+    private string $destination;
+    private int $code;
+
+    public function __construct(string $pattern, bool $exact, string $destination, int $code = 307) {
+      parent::__construct($pattern, $exact);
+      $this->destination = $destination;
+      $this->code = $code;
+    }
+
+    public function call(Router $router, array $params): string {
+      header("Location: $this->destination");
+      http_response_code($this->code);
+      return "";
+    }
+
+    protected function getArgs(): array {
+      return array_merge(parent::getArgs(), [$this->destination, $this->code]);
+    }
+  }
+
+  class DocumentRoute extends AbstractRoute {
+
+    private string $className;
+    private array $args;
+    private ?\ReflectionClass $reflectionClass;
+
+    public function __construct(string $pattern, bool $exact, string $className, ...$args) {
+      parent::__construct($pattern, $exact);
+      $this->className = $className;
+      $this->args = $args;
+      $this->reflectionClass = null;
+    }
+
+    private function loadClass(): bool {
+
+      if ($this->reflectionClass === null) {
+        try {
+          $file = getClassPath($this->className);
+          if (file_exists($file)) {
+            $this->reflectionClass = new \ReflectionClass($this->className);
+            if ($this->reflectionClass->isSubclassOf(Document::class)) {
+              return true;
+            }
+          }
+        } catch (ReflectionException $exception) {
+          $this->reflectionClass = null;
+          return false;
+        }
+
+        $this->reflectionClass = null;
+        return false;
+      }
+
+      return true;
+    }
+
+    public function match(string $url) {
+      $match = parent::match($url);
+      if ($match === false || !$this->loadClass()) {
+        return false;
+      }
+
+      return $match;
+    }
+
+    protected function getArgs(): array {
+      return array_merge(parent::getArgs(), [$this->className], $this->args);
+    }
+
+    public function call(Router $router, array $params): string {
+      if (!$this->loadClass()) {
+        return $router->returnStatusCode(500, [ "message" =>  "Error loading class: $this->className"]);
+      }
+
+      try {
+        $args = array_merge([$router->getUser()], $this->args);
+        $document = $this->reflectionClass->newInstanceArgs($args);
+        return $document->getCode($params);
+      } catch (\ReflectionException $e) {
+        return $router->returnStatusCode(500, [ "message" =>  "Error loading class $this->className: " . $e->getMessage()]);
+      }
+    }
+  }
+}

+ 6 - 3
core/Objects/User.class.php

@@ -30,7 +30,7 @@ class User extends ApiObject {
   public function __construct($configuration) {
     $this->configuration = $configuration;
     $this->reset();
-    $this->connectDb();
+    $this->connectDB();
 
     if (!is_cli()) {
       @session_start();
@@ -45,17 +45,20 @@ class User extends ApiObject {
     }
   }
 
-  private function connectDb() {
+  public function connectDB(): bool {
     $databaseConf = $this->configuration->getDatabase();
-    if($databaseConf) {
+    if ($databaseConf) {
       $this->sql = SQL::createConnection($databaseConf);
       if ($this->sql->isConnected()) {
         $settings = $this->configuration->getSettings();
         $settings->loadFromDatabase($this);
+        return true;
       }
     } else {
       $this->sql = null;
     }
+
+    return false;
   }
 
   public function getId(): int { return $this->uid; }

+ 0 - 4
core/Templates/404.twig

@@ -1,4 +0,0 @@
-{% extends "base.twig" %}
-{% block body %}
-    <b>Not found</b>
-{% endblock %}

+ 32 - 0
core/Templates/error_document.twig

@@ -0,0 +1,32 @@
+{% if var is null %}
+    {% set site = {'title': "#{status_code} - #{status_description}" } %}
+{% else %}
+    {% set site = site|merge({'title': "#{status_code} - #{status_description}"}) %}
+{% endif %}
+
+
+{% extends "base.twig" %}
+
+{% block head %}
+    <link rel="stylesheet" href="/css/bootstrap.min.css" type="text/css">
+    <script type="text/javascript" src="/js/bootstrap.bundle.min.js"></script>
+    <link rel="stylesheet" href="/css/fontawesome.min.css" type="text/css">
+{% endblock %}
+
+{% block body %}
+    <div class="container mt-5">
+        <div class="row">
+            <div class="col-lg-9 col-12 mx-auto">
+                <div class="jumbotron">
+                    <h1>{{ site.title }}!</h1>
+                    <hr class="my-4" />
+                    <p>
+                        Something went wrong or the site you wanted to visit does not exist anymore. <br />
+                        You can either <a href="javascript:history.back()">Go Back to previous page</a>
+                        or try to <a href="javascript:document.location.reload()">reload the page</a>.
+                    </p>
+                </div>
+            </div>
+        </div>
+    </div>
+{% endblock %}

+ 42 - 1
core/constants.php

@@ -19,4 +19,45 @@ function GroupName($index) {
   );
 
   return ($groupNames[$index] ?? "Unknown Group");
-}
+}
+
+// adapted from https://www.php.net/manual/en/function.http-response-code.php
+const HTTP_STATUS_DESCRIPTIONS = [
+  100 => 'Continue',
+  101 => 'Switching Protocols',
+  200 => 'OK',
+  201 => 'Created',
+  202 => 'Accepted',
+  203 => 'Non-Authoritative Information',
+  204 => 'No Content',
+  205 => 'Reset Content',
+  206 => 'Partial Content',
+  300 => 'Multiple Choices',
+  301 => 'Moved Permanently',
+  302 => 'Moved Temporarily',
+  303 => 'See Other',
+  304 => 'Not Modified',
+  305 => 'Use Proxy',
+  400 => 'Bad Request',
+  401 => 'Unauthorized',
+  402 => 'Payment Required',
+  403 => 'Forbidden',
+  404 => 'Not Found',
+  405 => 'Method Not Allowed',
+  406 => 'Not Acceptable',
+  407 => 'Proxy Authentication Required',
+  408 => 'Request Time-out',
+  409 => 'Conflict',
+  410 => 'Gone',
+  411 => 'Length Required',
+  412 => 'Precondition Failed',
+  413 => 'Request Entity Too Large',
+  414 => 'Request-URI Too Large',
+  415 => 'Unsupported Media Type',
+  500 => 'Internal Server Error',
+  501 => 'Not Implemented',
+  502 => 'Bad Gateway',
+  503 => 'Service Unavailable',
+  504 => 'Gateway Time-out',
+  505 => 'HTTP Version not supported',
+];

+ 10 - 1
core/core.php

@@ -5,7 +5,7 @@ if (is_file($autoLoad)) {
   require_once $autoLoad;
 }
 
-define("WEBBASE_VERSION", "1.4.5");
+define("WEBBASE_VERSION", "1.5.0");
 
 spl_autoload_extensions(".php");
 spl_autoload_register(function($class) {
@@ -215,6 +215,15 @@ function getClassPath($class, string $suffix = ".class"): string {
   return "core/$path$suffix.php";
 }
 
+function getClassName($class, bool $short = true): string {
+  $reflection = new \ReflectionClass($class);
+  if ($short) {
+    return $reflection->getShortName();
+  } else {
+    return $reflection->getName();
+  }
+}
+
 function createError($msg) {
   return json_encode(array("success" => false, "msg" => $msg));
 }

+ 26 - 63
index.php

@@ -14,8 +14,7 @@ if (is_file("MAINTENANCE") && !in_array($_SERVER['REMOTE_ADDR'], ['127.0.0.1', '
 
 use Api\Request;
 use Configuration\Configuration;
-use Documents\Document404;
-use Elements\Document;
+use Objects\Router;
 
 if (!is_readable(getClassPath(Configuration::class))) {
   header("Content-Type: application/json");
@@ -28,6 +27,8 @@ $sql    = $user->getSQL();
 $settings = $config->getSettings();
 $installation = !$sql || ($sql->isConnected() && !$settings->isInstalled());
 
+// API routes, prefix: /api/
+// TODO: move this to Router?
 if (isset($_GET["api"]) && is_string($_GET["api"])) {
   $isApiResponse = true;
   if ($installation) {
@@ -81,18 +82,10 @@ if (isset($_GET["api"]) && is_string($_GET["api"])) {
     }
   }
 } else {
-  $requestedUri = $_GET["site"] ?? $_SERVER["REQUEST_URI"];
-  if (($index = strpos($requestedUri, "?")) !== false) {
-    $requestedUri = substr($requestedUri, 0, $index);
-  }
 
-  if (($index = strpos($requestedUri, "#")) !== false) {
-    $requestedUri = substr($requestedUri, 0, $index);
-  }
-
-  if (startsWith($requestedUri, "/")) {
-    $requestedUri = substr($requestedUri, 1);
-  }
+  // all other routes
+  $requestedUri = $_GET["site"] ?? $_SERVER["REQUEST_URI"];
+  $requestedUri = Router::cleanURL($requestedUri);
 
   if ($installation) {
     if ($requestedUri !== "" && $requestedUri !== "index.php") {
@@ -104,61 +97,31 @@ if (isset($_GET["api"]) && is_string($_GET["api"])) {
     }
   } else {
 
-    $req = new \Api\Routes\Find($user);
-    $success = $req->execute(array("request" => $requestedUri));
-    $response = "";
-    if (!$success) {
-      http_response_code(500);
-      $response = "Unable to find route: " . $req->getLastError();
-    } else {
-      $route = $req->getResult()["route"];
-      if (is_null($route)) {
-        $response = (new Document404($user))->getCode();
-      } else {
-        $target = trim(explode("\n", $route["target"])[0]);
-        $extra = $route["extra"] ?? "";
-
-        $pattern = str_replace("/","\\/", $route["request"]);
-        $pattern = "/$pattern/i";
-        if (!startsWith($requestedUri, '/')) {
-          $requestedUri = "/$requestedUri";
-        }
+    $router = null;
 
-        @preg_match("$pattern", $requestedUri, $match);
-        if (is_array($match) && !empty($match)) {
-          foreach($match as $index => $value) {
-            $target = str_replace("$$index", $value, $target);
-            $extra  = str_replace("$$index", $value, $extra);
-          }
-        }
+    $routerCacheClass = '\Cache\RouterCache';
+    $routerCachePath = getClassPath($routerCacheClass);
+    if (is_file($routerCachePath)) {
+      @include_once $routerCachePath;
+      if (class_exists($routerCacheClass)) {
+        $router = new $routerCacheClass($user);
+      }
+    }
 
-        switch ($route["action"]) {
-          case "redirect_temporary":
-            http_response_code(307);
-            header("Location: $target");
-            break;
-          case "redirect_permanently":
-            http_response_code(308);
-            header("Location: $target");
-            break;
-          case "static":
-            $currentDir = dirname(__FILE__);
-            $response = serveStatic($currentDir, $target);
-            break;
-          case "dynamic":
-            $file = getClassPath($target);
-            if (!file_exists($file) || !is_subclass_of($target, Document::class)) {
-              $document = new Document404($user, $extra);
-            } else {
-              $document = new $target($user, $extra);
-            }
-
-            $response = $document->getCode();
-            break;
-        }
+    if ($router === null) {
+      $req = new \Api\Routes\GenerateCache($user);
+      if ($req->execute()) {
+        $router = $req->getRouter();
+      } else {
+        $message = "Unable to generate router cache: " . $req->getLastError();
+        $response = (new Router($user))->returnStatusCode(500, [ "message" => $message ]);
       }
     }
 
+    if ($router !== null) {
+      $response = $router->run($requestedUri);
+    }
+
     $user->processVisit();
   }
 }

File diff suppressed because it is too large
+ 0 - 0
js/admin.min.js


+ 4 - 0
test/Request.test.php

@@ -155,6 +155,10 @@ abstract class TestRequest extends Request {
     __new_die_impl($data);
     return true;
   }
+
+  protected function _execute(): bool {
+    return true;
+  }
 }
 
 class RequestAllMethods extends TestRequest {

+ 64 - 0
test/Router.test.php

@@ -0,0 +1,64 @@
+<?php
+
+require_once "core/Objects/Router.class.php";
+
+use Configuration\Configuration;
+use Objects\Router\EmptyRoute;
+use Objects\User;
+
+class RouterTest extends \PHPUnit\Framework\TestCase {
+
+  private static User $USER;
+
+  public static function setUpBeforeClass(): void {
+
+    $config = new Configuration();
+    RouterTest::$USER = new User($config);
+  }
+
+  public function testSimpleRoutes() {
+    $this->assertNotFalse((new EmptyRoute("/"))->match("/"));
+    $this->assertNotFalse((new EmptyRoute("/a"))->match("/a"));
+    $this->assertNotFalse((new EmptyRoute("/b/"))->match("/b/"));
+    $this->assertNotFalse((new EmptyRoute("/c/d"))->match("/c/d"));
+    $this->assertNotFalse((new EmptyRoute("/e/f/"))->match("/e/f/"));
+  }
+
+  public function testParamRoutes() {
+    $paramsEmpty = (new EmptyRoute("/"))->match("/");
+    $this->assertEquals([], $paramsEmpty);
+
+    $params1 = (new EmptyRoute("/:param"))->match("/test");
+    $this->assertEquals(["param" => "test"], $params1);
+
+    $params2 = (new EmptyRoute("/:param1/:param2"))->match("/test/123");
+    $this->assertEquals(["param1" => "test", "param2" => "123"], $params2);
+
+    $paramOptional1 = (new EmptyRoute("/:optional1?"))->match("/");
+    $this->assertEquals(["optional1" => null], $paramOptional1);
+
+    $paramOptional2 = (new EmptyRoute("/:optional2?"))->match("/yes");
+    $this->assertEquals(["optional2" => "yes"], $paramOptional2);
+
+    $paramOptional3 = (new EmptyRoute("/:optional3?/:optional4?"))->match("/1/2");
+    $this->assertEquals(["optional3" => "1", "optional4" => "2"], $paramOptional3);
+
+    $mixedRoute = new EmptyRoute("/:optional5?/:notOptional");
+    $paramMixed1 = $mixedRoute->match("/3/4");
+    $this->assertEquals(["optional5" => "3", "notOptional" => "4"], $paramMixed1);
+  }
+
+  public function testMixedRoute() {
+    $mixedRoute1 = new EmptyRoute("/:param/static");
+    $this->assertEquals(["param" => "yes"], $mixedRoute1->match("/yes/static"));
+
+    $mixedRoute2 = new EmptyRoute("/static/:param");
+    $this->assertEquals(["param" => "yes"], $mixedRoute2->match("/static/yes"));
+  }
+
+  public function testEmptyRoute() {
+    $router = new Objects\Router(self::$USER);
+    $emptyRoute = new EmptyRoute("/");
+    $this->assertEquals("", $emptyRoute->call($router, []));
+  }
+}

Some files were not shown because too many files changed in this diff