Browse Source

Twig, Tests, AES,

Roman 2 years ago
parent
commit
918244125c
74 changed files with 5108 additions and 1344 deletions
  1. 119 3
      cli.php
  2. 16 3
      core/Api/MailAPI.class.php
  3. 2 1
      core/Api/NotificationsAPI.class.php
  4. 4 3
      core/Api/PermissionAPI.class.php
  5. 113 11
      core/Api/Request.class.php
  6. 2 2
      core/Api/SettingsAPI.class.php
  7. 78 0
      core/Api/TemplateAPI.class.php
  8. 392 142
      core/Api/UserAPI.class.php
  9. 1 0
      core/Configuration/Settings.class.php
  10. 49 61
      core/Documents/Account.class.php
  11. 9 45
      core/Documents/Admin.class.php
  12. 9 55
      core/Documents/Document404.class.php
  13. 2 2
      core/Documents/Install.class.php
  14. 13 0
      core/Driver/SQL/Column/BigIntColumn.php
  15. 4 0
      core/Driver/SQL/Column/EnumColumn.class.php
  16. 13 1
      core/Driver/SQL/Column/IntColumn.class.php
  17. 7 7
      core/Driver/SQL/Condition/CondIn.class.php
  18. 18 0
      core/Driver/SQL/Expression/JsonArrayAgg.class.php
  19. 0 2
      core/Driver/SQL/Expression/Sum.class.php
  20. 7 2
      core/Driver/SQL/MySQL.class.php
  21. 6 1
      core/Driver/SQL/PostgreSQL.class.php
  22. 20 0
      core/Driver/SQL/Query/AlterTable.class.php
  23. 7 2
      core/Driver/SQL/Query/CreateTable.class.php
  24. 16 9
      core/Driver/SQL/SQL.class.php
  25. 32 37
      core/Elements/Document.class.php
  26. 79 0
      core/Elements/HtmlDocument.class.php
  27. 17 2
      core/Elements/Link.class.php
  28. 17 3
      core/Elements/Script.class.php
  29. 71 0
      core/Elements/TemplateDocument.class.php
  30. 1 0
      core/External/.gitignore
  31. 3 2
      core/External/PHPMailer/Exception.php
  32. 3 2
      core/External/PHPMailer/OAuth.php
  33. 340 135
      core/External/PHPMailer/PHPMailer.php
  34. 72 29
      core/External/PHPMailer/POP3.php
  35. 161 76
      core/External/PHPMailer/SMTP.php
  36. 117 47
      core/External/ZipStream/File.php
  37. 46 0
      core/External/ZipStream/FileStream.class.php
  38. 48 12
      core/External/ZipStream/ZipStream.php
  39. 8 0
      core/External/composer.json
  40. 2269 0
      core/External/composer.lock
  41. 33 53
      core/Objects/AesStream.class.php
  42. 0 3
      core/Objects/Language.class.php
  43. 18 12
      core/Objects/Session.class.php
  44. 22 10
      core/Objects/User.class.php
  45. 0 0
      core/TemplateCache/.gitkeep
  46. 4 0
      core/Templates/404.twig
  47. 36 0
      core/Templates/account.twig
  48. 46 0
      core/Templates/account/accept_invite.twig
  49. 37 0
      core/Templates/account/confirm_email.twig
  50. 50 0
      core/Templates/account/register.twig
  51. 27 0
      core/Templates/account/resend_confirm_email.twig
  52. 59 0
      core/Templates/account/reset_password.twig
  53. 12 0
      core/Templates/admin.twig
  54. 15 0
      core/Templates/base.twig
  55. 8 0
      core/Templates/mail/accept_invite.twig
  56. 8 0
      core/Templates/mail/confirm_email.twig
  57. 8 0
      core/Templates/mail/reset_password.twig
  58. 7 0
      core/Templates/redirect.twig
  59. 0 89
      core/Views/Account/AcceptInvite.class.php
  60. 0 61
      core/Views/Account/AccountView.class.php
  61. 0 55
      core/Views/Account/ConfirmEmail.class.php
  62. 0 70
      core/Views/Account/Register.class.php
  63. 0 39
      core/Views/Account/ResendConfirmEmail.class.php
  64. 0 94
      core/Views/Account/ResetPassword.class.php
  65. 0 20
      core/Views/Admin/AdminDashboardBody.class.php
  66. 0 72
      core/Views/Admin/LoginBody.class.php
  67. 0 13
      core/Views/View404.class.php
  68. 27 7
      core/core.php
  69. 46 41
      docker/nginx/site.conf
  70. 8 7
      index.php
  71. 3 1
      js/account.js
  72. 143 0
      test/AesStream.test.php
  73. 108 0
      test/Parameter.test.php
  74. 192 0
      test/Request.test.php

+ 119 - 3
cli.php

@@ -203,7 +203,7 @@ function handleDatabase(array $argv) {
     // 2nd: delete!
     foreach ($tables as $table => $uids) {
       $success = $sql->delete($table)
-        ->where(new CondIn("uid", $uids))
+        ->where(new CondIn(new Column("uid"), $uids))
         ->execute();
 
       if (!$success) {
@@ -342,6 +342,18 @@ function onMaintenance(array $argv) {
   }
 }
 
+function getConsoleWidth(): int {
+  $width = getenv('COLUMNS');
+  if (!$width) {
+    $width = exec('tput cols');
+    if (!$width) {
+      $width = 80; // default gnome-terminal column count
+    }
+  }
+
+  return intval($width);
+}
+
 function printTable(array $head, array $body) {
 
   $columns = [];
@@ -349,6 +361,7 @@ function printTable(array $head, array $body) {
     $columns[$key] = strlen($key);
   }
 
+  $maxWidth = getConsoleWidth();
   foreach ($body as $row) {
     foreach ($head as $key) {
       $value = $row[$key] ?? "";
@@ -364,14 +377,61 @@ function printTable(array $head, array $body) {
   printLine();
 
   foreach ($body as $row) {
+    $line = 0;
     foreach ($head as $key) {
-      echo str_pad($row[$key] ?? "", $columns[$key]) . '   ';
+      $width = min(max($maxWidth - $line, 0), $columns[$key]);
+      $line += $width;
+      echo str_pad($row[$key] ?? "", $width) . '   ';
     }
     printLine();
   }
 }
 
-// TODO: add missing api functions (should be all internal only i guess)
+function onSettings(array $argv) {
+  $user = getUser() or die();
+  $action = $argv[2] ?? "list";
+
+  if ($action === "list" || $action === "get") {
+    $key = (($action === "list" || count($argv) < 4) ? null : $argv[3]);
+    $req = new Api\Settings\Get($user);
+    $success = $req->execute(["key" => $key]);
+    if (!$success) {
+      _exit("Error listings settings: " . $req->getLastError());
+    } else {
+      $settings = [];
+      foreach ($req->getResult()["settings"] as $key => $value) {
+        $settings[] = ["key" => $key, "value" => $value];
+      }
+      printTable(["key", "value"], $settings);
+    }
+  } else if ($action === "set" || $action === "update") {
+    if (count($argv) < 5) {
+      _exit("Usage: $argv[0] settings $argv[2] <key> <value>");
+    } else {
+      $key = $argv[3];
+      $value = $argv[4];
+      $req = new Api\Settings\Set($user);
+      $success = $req->execute(["settings" => [$key => $value]]);
+      if (!$success) {
+        _exit("Error updating settings: " . $req->getLastError());
+      }
+    }
+  } else if ($action === "unset" || $action === "delete") {
+    if (count($argv) < 4) {
+      _exit("Usage: $argv[0] settings $argv[2] <key>");
+    } else {
+      $key = $argv[3];
+      $req = new Api\Settings\Set($user);
+      $success = $req->execute(["settings" => [$key => null]]);
+      if (!$success) {
+        _exit("Error updating settings: " . $req->getLastError());
+      }
+    }
+  } else {
+    _exit("Usage: $argv[0] settings <get|set|unset>");
+  }
+}
+
 function onRoutes(array $argv) {
 
   $user = getUser() or die();
@@ -459,7 +519,60 @@ function onRoutes(array $argv) {
 }
 
 function onTest($argv) {
+  $files = glob(WEBROOT . '/test/*.test.php');
+  $requestedTests = array_filter(array_slice($argv, 2), function ($t) {
+    return !startsWith($t, "-");
+  });
+  $verbose = in_array("-v", $requestedTests);
+
+  foreach ($files as $file) {
+    include_once $file;
+    $baseName = substr(basename($file), 0, - strlen(".test.php"));
+    if (!empty($requestedTests) && !in_array($baseName, $requestedTests)) {
+      continue;
+    }
 
+    $className =  $baseName . "Test";
+    if (class_exists($className)) {
+      echo "=== Running $className ===" . PHP_EOL;
+      $testClass = new \PHPUnit\Framework\TestSuite();
+      $testClass->addTestSuite($className);
+      $result = $testClass->run();
+      echo "Done after " . $result->time() . "s" . PHP_EOL;
+      $stats = [
+        "total" => $result->count(),
+        "skipped" => $result->skippedCount(),
+        "error" => $result->errorCount(),
+        "failure" => $result->failureCount(),
+        "warning" => $result->warningCount(),
+      ];
+
+      // Summary
+      echo implode(", ", array_map(function ($key) use ($stats) {
+          return "$key: " . $stats[$key];
+        }, array_keys($stats))) . PHP_EOL;
+
+      $reports = array_merge($result->errors(), $result->failures());
+      foreach ($reports as $error) {
+        $exception = $error->thrownException();
+        echo $error->toString();
+        if ($verbose) {
+          echo ". Stacktrace:" . PHP_EOL . $exception->getTraceAsString() . PHP_EOL;
+        } else {
+          $location = array_filter($exception->getTrace(), function ($t) use ($file) {
+            return isset($t["file"]) && $t["file"] === $file;
+          });
+          $location = array_reverse($location);
+          $location = array_pop($location);
+          if ($location)  {
+            echo " in " . substr($location["file"], strlen(WEBROOT)) . "#" . $location["line"] . PHP_EOL;
+          } else {
+            echo PHP_EOL;
+          }
+        }
+      }
+    }
+  }
 }
 
 function onMail($argv) {
@@ -507,6 +620,9 @@ switch ($command) {
   case 'mail':
     onMail($argv);
     break;
+  case 'settings':
+    onSettings($argv);
+    break;
   default:
     printLine("Unknown command '$command'");
     printLine();

+ 16 - 3
core/Api/MailAPI.class.php

@@ -38,9 +38,14 @@ namespace Api\Mail {
   use Api\MailAPI;
   use Api\Parameter\Parameter;
   use Api\Parameter\StringType;
+  use DateTimeInterface;
   use Driver\SQL\Column\Column;
   use Driver\SQL\Condition\Compare;
+  use Driver\SQL\Condition\CondBool;
   use Driver\SQL\Condition\CondIn;
+  use Driver\SQL\Condition\CondNot;
+  use Driver\SQL\Expression\CurrentTimeStamp;
+  use Driver\SQL\Expression\JsonArrayAgg;
   use Driver\SQL\Strategy\UpdateStrategy;
   use External\PHPMailer\Exception;
   use External\PHPMailer\PHPMailer;
@@ -100,6 +105,14 @@ namespace Api\Mail {
       $subject = $this->getParam('subject');
       $replyTo = $this->getParam('replyTo');
       $replyName = $this->getParam('replyName');
+      $body = $this->getParam('body');
+
+      if (stripos($body, "<body") === false) {
+        $body = "<body>$body</body>";
+      }
+      if (stripos($body, "<html") === false) {
+        $body = "<html>$body</html>";
+      }
 
       try {
         $mail = new PHPMailer;
@@ -119,9 +132,9 @@ namespace Api\Mail {
         $mail->Username = $mailConfig->getLogin();
         $mail->Password = $mailConfig->getPassword();
         $mail->SMTPSecure = 'tls';
-        $mail->IsHTML(true);
         $mail->CharSet = 'UTF-8';
-        $mail->Body = $this->getParam('body');
+        $mail->msgHTML($body);
+        $mail->AltBody = strip_tags($body);
 
         $this->success = @$mail->Send();
         if (!$this->success) {
@@ -212,7 +225,7 @@ namespace Api\Mail {
       if ($this->success && count($entityIds) > 0) {
         $sql->update("EntityLog")
           ->set("modified", $sql->now())
-          ->where(new CondIn("entityId", $entityIds))
+          ->where(new CondIn(new Column("entityId"), $entityIds))
           ->execute();
       }
 

+ 2 - 1
core/Api/NotificationsAPI.class.php

@@ -11,6 +11,7 @@ namespace Api\Notifications {
   use Api\NotificationsAPI;
   use Api\Parameter\Parameter;
   use Api\Parameter\StringType;
+  use Driver\SQL\Column\Column;
   use Driver\SQL\Condition\Compare;
   use Driver\SQL\Condition\CondIn;
   use Driver\SQL\Query\Select;
@@ -252,7 +253,7 @@ namespace Api\Notifications {
       if ($this->success) {
         $res = $sql->update("GroupNotification")
           ->set("seen", true)
-          ->where(new CondIn("group_id",
+          ->where(new CondIn(new Column("group_id"),
             $sql->select("group_id")
               ->from("UserGroup")
               ->where(new Compare("user_id", $this->user->getId()))))

+ 4 - 3
core/Api/PermissionAPI.class.php

@@ -61,8 +61,9 @@ namespace Api\Permission {
           return true;
         }
 
-        if (!$this->user->isLoggedIn() || empty(array_intersect($groups, array_keys($this->user->getGroups())))) {
-          header('HTTP 1.1 401 Unauthorized');
+        $userGroups = $this->user->getGroups();
+        if (empty($userGroups) || empty(array_intersect($groups, array_keys($this->user->getGroups())))) {
+          http_response_code(401);
           return $this->createError("Permission denied.");
         }
       }
@@ -197,7 +198,7 @@ namespace Api\Permission {
       if ($this->success) {
         $res = $sql->delete("ApiPermission")
           ->where(new Compare("description", "")) // only delete non default permissions
-          ->where(new CondNot(new CondIn("method", $insertedMethods)))
+          ->where(new CondNot(new CondIn(new Column("method"), $insertedMethods)))
           ->execute();
 
         $this->success = ($res !== FALSE);

+ 113 - 11
core/Api/Request.class.php

@@ -45,6 +45,17 @@ class Request {
     }
   }
 
+  protected function allowMethod($method) {
+    $availableMethods = ["GET", "HEAD", "POST", "PUT", "DELETE", "PATCH", "TRACE", "CONNECT"];
+    if (in_array($method, $availableMethods) && !in_array($method, $this->allowedMethods)) {
+      $this->allowedMethods[] = $method;
+    }
+  }
+
+  protected function getRequestMethod() {
+    return $_SERVER["REQUEST_METHOD"];
+  }
+
   public function parseParams($values, $structure = NULL): bool {
 
     if ($structure === NULL) {
@@ -80,6 +91,11 @@ class Request {
     }
   }
 
+  // wrapper for unit tests
+  protected function _die(string $data = ""): bool {
+    die($data);
+  }
+
   public function execute($values = array()): bool {
     $this->params = array_merge([], $this->defaultParams);
     $this->success = false;
@@ -98,7 +114,7 @@ class Request {
           $values = array_merge($values, $jsonData);
         } else {
           $this->lastError = 'Invalid request body.';
-          header('HTTP 1.1 400 Bad Request');
+          http_response_code(400);
           return false;
         }
       }
@@ -106,39 +122,48 @@ class Request {
 
     if ($this->isDisabled) {
       $this->lastError = "This function is currently disabled.";
+      http_response_code(503);
       return false;
     }
 
     if ($this->externalCall && !$this->isPublic) {
       $this->lastError = 'This function is private.';
-      header('HTTP 1.1 403 Forbidden');
+      http_response_code(403);
       return false;
     }
 
     if ($this->externalCall) {
 
+      if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
+        http_response_code(204); # No content
+        header("Allow: OPTIONS, " . implode(", ", $this->allowedMethods));
+        return $this->_die();
+      }
+
       // check the request method
       if (!in_array($_SERVER['REQUEST_METHOD'], $this->allowedMethods)) {
         $this->lastError = 'This method is not allowed';
-        header('HTTP 1.1 405 Method Not Allowed');
+        http_response_code(405);
         return false;
       }
 
       $apiKeyAuthorized = false;
 
-      // Logged in or api key authorized?
-      if ($this->loginRequired) {
-        if (isset($_SERVER["HTTP_AUTHORIZATION"]) && $this->apiKeyAllowed) {
+      if (!$this->user->isLoggedIn() && $this->apiKeyAllowed) {
+        if (isset($_SERVER["HTTP_AUTHORIZATION"])) {
           $authHeader = $_SERVER["HTTP_AUTHORIZATION"];
           if (startsWith($authHeader, "Bearer ")) {
             $apiKey = substr($authHeader, strlen("Bearer "));
             $apiKeyAuthorized = $this->user->authorize($apiKey);
           }
         }
+      }
 
+      // Logged in or api key authorized?
+      if ($this->loginRequired) {
         if (!$this->user->isLoggedIn() && !$apiKeyAuthorized) {
           $this->lastError = 'You are not logged in.';
-          header('HTTP 1.1 401 Unauthorized');
+          http_response_code(401);
           return false;
         }
       }
@@ -149,7 +174,7 @@ class Request {
         // if it's not a call with API_KEY, check for csrf_token
         if (!isset($values["csrf_token"]) || strcmp($values["csrf_token"], $this->user->getSession()->getCsrfToken()) !== 0) {
           $this->lastError = "CSRF-Token mismatch";
-          header('HTTP 1.1 403 Forbidden');
+          http_response_code(403);
           return false;
         }
       }
@@ -235,9 +260,6 @@ class Request {
   }
 
   protected function disableOutputBuffer() {
-    header('X-Accel-Buffering: no');
-    header("Cache-Control: no-transform, no-store, max-age=0");
-
     ob_implicit_flush(true);
     $levels = ob_get_level();
     for ( $i = 0; $i < $levels; $i ++ ) {
@@ -245,4 +267,84 @@ class Request {
     }
     flush();
   }
+
+  protected function setupSSE() {
+    $this->user->getSQL()->close();
+    $this->user->sendCookies();
+    set_time_limit(0);
+    ignore_user_abort(true);
+    header('Content-Type: text/event-stream');
+    header('Connection: keep-alive');
+    header('X-Accel-Buffering: no');
+    header('Cache-Control: no-cache');
+
+    $this->disableOutputBuffer();
+  }
+
+  protected function processImageUpload(string $uploadDir, array $allowedExtensions = ["jpg","jpeg","png","gif"], $transformCallback = null) {
+    if (empty($_FILES)) {
+      return $this->createError("You need to upload an image.");
+    } else if (count($_FILES) > 1) {
+      return $this->createError("You can only upload one image at once.");
+    }
+
+    $upload = array_values($_FILES)[0];
+    if (is_array($upload["name"])) {
+      return $this->createError("You can only upload one image at once.");
+    } else if ($upload["error"] !== UPLOAD_ERR_OK) {
+      return $this->createError("There was an error uploading the image, code: " . $upload["error"]);
+    }
+
+    $imageName = $upload["name"];
+    $ext = strtolower(pathinfo($imageName, PATHINFO_EXTENSION));
+    if (!in_array($ext, $allowedExtensions)) {
+      return $this->createError("Only the following file extensions are allowed: " . implode(",", $allowedExtensions));
+    }
+
+    if (!is_dir($uploadDir) && !mkdir($uploadDir, 0777, true)) {
+      return $this->createError("Upload directory does not exist and could not be created.");
+    }
+
+    $srcPath = $upload["tmp_name"];
+    $mimeType = mime_content_type($srcPath);
+    if (!startsWith($mimeType, "image/")) {
+      return $this->createError("Uploaded file is not an image.");
+    }
+
+    try {
+      $image = new \Imagick($srcPath);
+
+      // strip exif
+      $profiles = $image->getImageProfiles("icc", true);
+      $image->stripImage();
+      if (!empty($profiles)) {
+        $image->profileImage("icc", $profiles['icc']);
+      }
+    } catch (\ImagickException $ex) {
+      return $this->createError("Error loading image: " . $ex->getMessage());
+    }
+
+    try {
+      if ($transformCallback) {
+        $fileName = call_user_func([$this, $transformCallback], $image, $uploadDir);
+      } else {
+
+        $image->writeImage($srcPath);
+        $image->destroy();
+
+        $uuid = uuidv4();
+        $fileName = "$uuid.$ext";
+        $destPath = "$uploadDir/$fileName";
+        if (!file_exists($destPath)) {
+          if (!@move_uploaded_file($srcPath, $destPath)) {
+            return $this->createError("Could not store uploaded file.");
+          }
+        }
+      }
+
+      return [$fileName, $imageName];
+    } catch (\ImagickException $ex) {
+      return $this->createError("Error processing image: " . $ex->getMessage());
+    }
+  }
 }

+ 2 - 2
core/Api/SettingsAPI.class.php

@@ -141,7 +141,7 @@ namespace Api\Settings {
       $res = $sql->select("name")
         ->from("Settings")
         ->where(new CondBool("readonly"))
-        ->where(new CondIn("name", $keys))
+        ->where(new CondIn(new Column("name"), $keys))
         ->limit(1)
         ->execute();
 
@@ -158,7 +158,7 @@ namespace Api\Settings {
     private function deleteKeys(array $keys) {
       $sql = $this->user->getSQL();
       $res = $sql->delete("Settings")
-        ->where(new CondIn("name", $keys))
+        ->where(new CondIn(new Column("name"), $keys))
         ->execute();
 
       $this->success = ($res !== FALSE);

+ 78 - 0
core/Api/TemplateAPI.class.php

@@ -0,0 +1,78 @@
+<?php
+
+namespace Api {
+
+  use Objects\User;
+
+  abstract class TemplateAPI extends Request {
+    function __construct(User $user, bool $externalCall = false, array $params = array()) {
+      parent::__construct($user, $externalCall, $params);
+      $this->isPublic = false; // internal API
+    }
+  }
+
+}
+
+namespace Api\Template {
+
+  use Api\Parameter\ArrayType;
+  use Api\Parameter\Parameter;
+  use Api\Parameter\StringType;
+  use Api\TemplateAPI;
+  use Objects\User;
+  use Twig\Environment;
+  use Twig\Error\LoaderError;
+  use Twig\Error\RuntimeError;
+  use Twig\Error\SyntaxError;
+  use Twig\Loader\FilesystemLoader;
+
+  class Render extends TemplateAPI {
+
+    public function __construct(User $user, bool $externalCall = false) {
+      parent::__construct($user, $externalCall, [
+        "file" => new StringType("file"),
+        "parameters" => new ArrayType("parameters", Parameter::TYPE_MIXED, false, true, [])
+      ]);
+    }
+
+    public function execute($values = array()): bool {
+      if (!parent::execute($values)) {
+        return false;
+      }
+
+      $templateFile = $this->getParam("file");
+      $parameters   = $this->getParam("parameters");
+      $extension = pathinfo($templateFile, PATHINFO_EXTENSION);
+      $allowedExtensions = ["html", "twig"];
+
+      if (!in_array($extension, $allowedExtensions)) {
+        return $this->createError("Invalid template file extension. Allowed: " . implode(",", $allowedExtensions));
+      }
+
+      $templateDir = WEBROOT . "/core/Templates/";
+      $templateCache = WEBROOT . "/core/TemplateCache/";
+      $path = realpath($templateDir . $templateFile);
+      if (!startsWith($path, realpath($templateDir))) {
+        return $this->createError("Template file not in template directory");
+      } else if (!is_file($path)) {
+        return $this->createError("Template file not found");
+      }
+
+      $twigLoader = new FilesystemLoader($templateDir);
+      $twigEnvironment = new Environment($twigLoader, [
+        'cache' => $templateCache,
+        'auto_reload' => true
+      ]);
+
+      try {
+        $this->result["html"] = $twigEnvironment->render($templateFile, $parameters);
+      } catch (LoaderError | RuntimeError | SyntaxError $e) {
+        return $this->createError("Error rendering twig template: " . $e->getMessage());
+      }
+
+      return $this->success;
+    }
+
+  }
+
+}

+ 392 - 142
core/Api/UserAPI.class.php

@@ -6,7 +6,7 @@ namespace Api {
 
   abstract class UserAPI extends Request {
 
-    protected function userExists(?string $username, ?string $email = null) {
+    protected function userExists(?string $username, ?string $email = null): bool {
 
       $conditions = array();
       if ($username) {
@@ -42,8 +42,8 @@ namespace Api {
       return $this->success;
     }
 
-    protected function checkPasswordRequirements($password, $confirmPassword) {
-      if(strcmp($password, $confirmPassword) !== 0) {
+    protected function checkPasswordRequirements($password, $confirmPassword): bool {
+      if ((($password === null) !== ($confirmPassword === null)) || strcmp($password, $confirmPassword) !== 0) {
         return $this->createError("The given passwords do not match");
       } else if(strlen($password) < 6) {
         return $this->createError("The password should be at least 6 characters long");
@@ -91,7 +91,8 @@ namespace Api {
 
     protected function getUser($id) {
       $sql = $this->user->getSQL();
-      $res = $sql->select("User.uid as userId", "User.name", "User.email", "User.registered_at", "User.confirmed",
+      $res = $sql->select("User.uid as userId", "User.name", "User.fullName", "User.email",
+        "User.registered_at", "User.confirmed", "User.last_online", "User.profilePicture",
         "Group.uid as groupId", "Group.name as groupName", "Group.color as groupColor")
         ->from("User")
         ->leftJoin("UserGroup", "User.uid", "UserGroup.user_id")
@@ -105,24 +106,6 @@ namespace Api {
       return ($this->success && !empty($res) ? $res : array());
     }
 
-    protected function getMessageTemplate($key) {
-      $req = new \Api\Settings\Get($this->user);
-      $this->success = $req->execute(array("key" => "^($key|mail_enabled)$"));
-      $this->lastError = $req->getLastError();
-
-      if ($this->success) {
-        $settings = $req->getResult()["settings"];
-        $isEnabled = ($settings["mail_enabled"] ?? "0") === "1";
-        if (!$isEnabled) {
-          return $this->createError("Mail is not enabled.");
-        }
-
-        return $settings[$key] ?? "{{link}}";
-      }
-
-      return $this->success;
-    }
-
     protected function invalidateToken($token) {
       $this->user->getSQL()
         ->update("UserToken")
@@ -142,6 +125,14 @@ namespace Api {
       $this->lastError = $sql->getLastError();
       return $this->success;
     }
+
+    protected function formatDuration(int $count, string $string): string {
+      if ($count === 1) {
+        return $string;
+      } else {
+        return "the next $count ${string}s";
+      }
+    }
   }
 
 }
@@ -150,12 +141,17 @@ namespace Api\User {
 
   use Api\Parameter\Parameter;
   use Api\Parameter\StringType;
+  use Api\Template\Render;
   use Api\UserAPI;
   use Api\VerifyCaptcha;
   use DateTime;
+  use Driver\SQL\Column\Column;
   use Driver\SQL\Condition\Compare;
   use Driver\SQL\Condition\CondBool;
   use Driver\SQL\Condition\CondIn;
+  use Driver\SQL\Condition\CondNot;
+  use Driver\SQL\Expression\JsonArrayAgg;
+  use ImagickException;
   use Objects\User;
 
   class Create extends UserAPI {
@@ -239,10 +235,10 @@ namespace Api\User {
       $this->success = ($res !== NULL);
       $this->lastError = $sql->getLastError();
 
-      if ($this->success) {
-        $ids = array();
-        foreach($res as $row) $ids[] = $row["uid"];
-        return $ids;
+      if ($this->success && is_array($res)) {
+        return array_map(function ($row) {
+            return intval($row["uid"]);
+          }, $res);
       }
 
       return false;
@@ -274,11 +270,12 @@ namespace Api\User {
 
       $sql = $this->user->getSQL();
       $res = $sql->select("User.uid as userId", "User.name", "User.email", "User.registered_at", "User.confirmed",
-        "Group.uid as groupId", "Group.name as groupName", "Group.color as groupColor")
+        "User.profilePicture", "User.fullName", "Group.uid as groupId", "User.last_online",
+        "Group.name as groupName", "Group.color as groupColor")
         ->from("User")
         ->leftJoin("UserGroup", "User.uid", "UserGroup.user_id")
         ->leftJoin("Group", "Group.uid", "UserGroup.group_id")
-        ->where(new CondIn("User.uid", $userIds))
+        ->where(new CondIn(new Column("User.uid"), $userIds))
         ->execute();
 
       $this->success = ($res !== FALSE);
@@ -291,15 +288,29 @@ namespace Api\User {
           $groupId = intval($row["groupId"]);
           $groupName = $row["groupName"];
           $groupColor = $row["groupColor"];
+
+          $fullInfo = ($userId === $this->user->getId()) ||
+            ($this->user->hasGroup(USER_GROUP_ADMIN) || $this->user->hasGroup(USER_GROUP_SUPPORT));
+
           if (!isset($this->result["users"][$userId])) {
-            $this->result["users"][$userId] = array(
+            $user = array(
               "uid" => $userId,
               "name" => $row["name"],
+              "fullName" => $row["fullName"],
+              "profilePicture" => $row["profilePicture"],
               "email" => $row["email"],
-              "registered_at" => $row["registered_at"],
               "confirmed" => $sql->parseBool($row["confirmed"]),
               "groups" => array(),
             );
+
+            if ($fullInfo) {
+              $user["registered_at"] = $row["registered_at"];
+              $user["last_online"] = $row["last_online"];
+            } else if (!$sql->parseBool($row["confirmed"])) {
+              continue;
+            }
+
+            $this->result["users"][$userId] = $user;
           }
 
           if (!is_null($groupId)) {
@@ -323,6 +334,7 @@ namespace Api\User {
       parent::__construct($user, $externalCall, array(
         'id' => new Parameter('id', Parameter::TYPE_INT)
       ));
+      $this->loginRequired = true;
     }
 
     public function execute($values = array()): bool {
@@ -331,30 +343,79 @@ namespace Api\User {
       }
 
       $sql = $this->user->getSQL();
-      $id = $this->getParam("id");
-      $user = $this->getUser($id);
-
+      $userId = $this->getParam("id");
+      $user = $this->getUser($userId);
       if ($this->success) {
         if (empty($user)) {
           return $this->createError("User not found");
         } else {
-          $this->result["user"] = array(
-            "uid" => $user[0]["userId"],
+
+          $queriedUser = array(
+            "uid" => $userId,
             "name" => $user[0]["name"],
+            "fullName" => $user[0]["fullName"],
             "email" => $user[0]["email"],
             "registered_at" => $user[0]["registered_at"],
+            "last_online" => $user[0]["last_online"],
+            "profilePicture" => $user[0]["profilePicture"],
             "confirmed" => $sql->parseBool($user["0"]["confirmed"]),
-            "groups" => array()
+            "groups" => array(),
           );
 
           foreach($user as $row) {
             if (!is_null($row["groupId"])) {
-              $this->result["user"]["groups"][$row["groupId"]] = array(
+              $queriedUser["groups"][$row["groupId"]] = array(
                 "name" => $row["groupName"],
                 "color" => $row["groupColor"],
               );
             }
           }
+
+          // either we are querying own info or we are internal employees
+          // as internal employees can add arbitrary users to projects
+          $canView = ($userId === $this->user->getId() ||
+                $this->user->hasGroup(USER_GROUP_ADMIN) ||
+                $this->user->hasGroup(USER_GROUP_SUPPORT));
+
+          // full info only when we have administrative privileges, or we are querying ourselves
+          $fullInfo = ($userId === $this->user->getId()) ||
+            ($this->user->hasGroup(USER_GROUP_ADMIN) || $this->user->hasGroup(USER_GROUP_SUPPORT));
+
+          if (!$canView) {
+            $res = $sql->select(new JsonArrayAgg(new Column("projectId"), "projectIds"))
+              ->from("ProjectMember")
+              ->where(new Compare("userId", $this->user->getId()), new Compare("userId", $userId))
+              ->groupBy("projectId")
+              ->execute();
+
+            $this->success = ($res !== false);
+            $this->lastError = $sql->getLastError();
+            if (!$this->success ) {
+              return false;
+            } else if (is_array($res)) {
+              foreach ($res as $row) {
+                if (count(json_decode($row["projectIds"])) > 1) {
+                  $canView = true;
+                  break;
+                }
+              }
+            }
+          }
+
+          if (!$canView) {
+            return $this->createError("No permissions to access this user");
+          }
+
+          if (!$fullInfo) {
+            if (!$queriedUser["confirmed"]) {
+              return $this->createError("No permissions to access this user");
+            }
+            unset($queriedUser["registered_at"]);
+            unset($queriedUser["confirmed"]);
+            unset($queriedUser["last_online"]);
+          }
+
+          $this->result["user"] = $queriedUser;
         }
       }
 
@@ -424,11 +485,6 @@ namespace Api\User {
         return false;
       }
 
-      $messageBody = $this->getMessageTemplate("message_accept_invite");
-      if ($messageBody === false) {
-        return false;
-      }
-
       // Create user
       $id = $this->insertUser($username, $email, "", false);
       if (!$this->success) {
@@ -437,7 +493,8 @@ namespace Api\User {
 
       // Create Token
       $token = generateRandomString(36);
-      $valid_until = (new DateTime())->modify("+7 day");
+      $validDays = 7;
+      $valid_until = (new DateTime())->modify("+$validDays day");
       $sql = $this->user->getSQL();
       $res = $sql->insert("UserToken", array("user_id", "token", "token_type", "valid_until"))
         ->addRow($id, $token, "invite", $valid_until)
@@ -449,29 +506,34 @@ namespace Api\User {
       if ($this->success) {
 
         $settings = $this->user->getConfiguration()->getSettings();
-        $baseUrl = htmlspecialchars($settings->getBaseUrl());
-        $siteName = htmlspecialchars($settings->getSiteName());
+        $baseUrl = $settings->getBaseUrl();
+        $siteName = $settings->getSiteName();
+
+        $req = new Render($this->user);
+        $this->success = $req->execute([
+          "file" => "mail/accept_invite.twig",
+          "parameters" => [
+            "link" => "$baseUrl/acceptInvite?token=$token",
+            "site_name" => $siteName,
+            "base_url" => $baseUrl,
+            "username" => $username,
+            "valid_time" => $this->formatDuration($validDays, "day")
+          ]
+        ]);
+        $this->lastError = $req->getLastError();
 
-        $replacements = array(
-          "link" => "$baseUrl/acceptInvite?token=$token",
-          "site_name" => $siteName,
-          "base_url" => $baseUrl,
-          "username" => htmlspecialchars($username)
-        );
+        if ($this->success) {
+          $messageBody = $req->getResult()["html"];
+          $request = new \Api\Mail\Send($this->user);
+          $this->success = $request->execute(array(
+            "to" => $email,
+            "subject" => "[$siteName] Account Invitation",
+            "body" => $messageBody
+          ));
 
-        foreach($replacements as $key => $value) {
-          $messageBody = str_replace("{{{$key}}}", $value, $messageBody);
+          $this->lastError = $request->getLastError();
         }
 
-        $request = new \Api\Mail\Send($this->user);
-        $this->success = $request->execute(array(
-          "to" => $email,
-          "subject" => "[$siteName] Account Invitation",
-          "body" => $messageBody
-        ));
-
-        $this->lastError = $request->getLastError();
-
         if (!$this->success) {
           $this->lastError = "The invitation was created but the confirmation email could not be sent. " .
             "Please contact the server administration. Reason: " . $this->lastError;
@@ -607,7 +669,7 @@ namespace Api\User {
 
     public function __construct($user, $externalCall = false) {
       parent::__construct($user, $externalCall, array(
-        'username' => new StringType('username', 32),
+        'username' => new StringType('username'),
         'password' => new StringType('password'),
         'stayLoggedIn' => new Parameter('stayLoggedIn', Parameter::TYPE_BOOLEAN, true, true)
       ));
@@ -641,7 +703,8 @@ namespace Api\User {
       $sql = $this->user->getSQL();
       $res = $sql->select("User.uid", "User.password", "User.confirmed")
         ->from("User")
-        ->where(new Compare("User.name", $username))
+        ->where(new Compare("User.name", $username), new Compare("User.email", $username))
+        ->limit(1)
         ->execute();
 
       $this->success = ($res !== FALSE);
@@ -753,35 +816,38 @@ namespace Api\User {
         return false;
       }
 
-      $messageBody = $this->getMessageTemplate("message_confirm_email");
-      if ($messageBody === false) {
-        return false;
-      }
-
       $this->userId = $this->insertUser($username, $email, $password, false);
       if (!$this->success) {
         return false;
       }
 
-      $this->token = generateRandomString(36);
-      if ($this->insertToken($this->userId, $this->token, "email_confirm", 48)) {
-        $settings = $this->user->getConfiguration()->getSettings();
-        $baseUrl = htmlspecialchars($settings->getBaseUrl());
-        $siteName = htmlspecialchars($settings->getSiteName());
+      // add internal group
+      $this->user->getSQL()->insert("UserGroup", ["user_id", "group_id"])
+        ->addRow($this->userId, USER_GROUP_INTERNAL)
+        ->execute();
 
-        if ($this->success) {
+      $validHours = 48;
+      $this->token = generateRandomString(36);
+      if ($this->insertToken($this->userId, $this->token, "email_confirm", $validHours)) {
 
-          $replacements = array(
+        $settings = $this->user->getConfiguration()->getSettings();
+        $baseUrl = $settings->getBaseUrl();
+        $siteName = $settings->getSiteName();
+        $req = new Render($this->user);
+        $this->success = $req->execute([
+          "file" => "mail/confirm_email.twig",
+          "parameters" => [
             "link" => "$baseUrl/confirmEmail?token=$this->token",
             "site_name" => $siteName,
             "base_url" => $baseUrl,
-            "username" => htmlspecialchars($username)
-          );
-
-          foreach($replacements as $key => $value) {
-            $messageBody = str_replace("{{{$key}}}", $value, $messageBody);
-          }
+            "username" => $username,
+            "valid_time" => $this->formatDuration($validHours, "hour")
+          ]
+        ]);
+        $this->lastError = $req->getLastError();
 
+        if ($this->success) {
+          $messageBody = $req->getResult()["html"];
           $request = new \Api\Mail\Send($this->user);
           $this->success = $request->execute(array(
             "to" => $email,
@@ -862,6 +928,7 @@ namespace Api\User {
       parent::__construct($user, $externalCall, array(
         'id' => new Parameter('id', Parameter::TYPE_INT),
         'username' => new StringType('username', 32, true, NULL),
+        'fullName' => new StringType('fullName', 64, true, NULL),
         'email' => new Parameter('email', Parameter::TYPE_EMAIL, true, NULL),
         'password' => new StringType('password', -1, true, NULL),
         'groups' => new Parameter('groups', Parameter::TYPE_ARRAY, true, NULL),
@@ -886,6 +953,7 @@ namespace Api\User {
         }
 
         $username = $this->getParam("username");
+        $fullName = $this->getParam("fullName");
         $email = $this->getParam("email");
         $password = $this->getParam("password");
         $groups = $this->getParam("groups");
@@ -913,6 +981,7 @@ namespace Api\User {
 
         // Check for duplicate username, email
         $usernameChanged = !is_null($username) && strcasecmp($username, $user[0]["name"]) !== 0;
+        $fullNameChanged = !is_null($fullName) && strcasecmp($fullName, $user[0]["fullName"]) !== 0;
         $emailChanged = !is_null($email) && strcasecmp($email, $user[0]["email"]) !== 0;
         if($usernameChanged || $emailChanged) {
           if (!$this->userExists($usernameChanged ? $username : NULL, $emailChanged ? $email : NULL)) {
@@ -924,6 +993,7 @@ namespace Api\User {
         $query = $sql->update("User");
 
         if ($usernameChanged) $query->set("name", $username);
+        if ($fullNameChanged) $query->set("fullName", $fullName);
         if ($emailChanged) $query->set("email", $email);
         if (!is_null($password)) $query->set("password", $this->hashPassword($password));
 
@@ -1028,50 +1098,51 @@ namespace Api\User {
         }
       }
 
-      $messageBody = $this->getMessageTemplate("message_reset_password");
-      if ($messageBody === false) {
-        return false;
-      }
-
       $email = $this->getParam("email");
       $user = $this->findUser($email);
-      if ($user === false) {
+      if ($this->success === false) {
         return false;
       }
 
       if ($user !== null) {
+        $validHours = 1;
         $token = generateRandomString(36);
-        if (!$this->insertToken($user["uid"], $token, "password_reset", 1)) {
+        if (!$this->insertToken($user["uid"], $token, "password_reset", $validHours)) {
           return false;
         }
 
-        $baseUrl = htmlspecialchars($settings->getBaseUrl());
-        $siteName = htmlspecialchars($settings->getSiteName());
+        $baseUrl = $settings->getBaseUrl();
+        $siteName = $settings->getSiteName();
 
-        $replacements = array(
-          "link" => "$baseUrl/resetPassword?token=$token",
-          "site_name" => $siteName,
-          "base_url" => $baseUrl,
-          "username" => htmlspecialchars($user["name"])
-        );
+        $req = new Render($this->user);
+        $this->success = $req->execute([
+          "file" => "mail/reset_password.twig",
+          "parameters" => [
+            "link" => "$baseUrl/resetPassword?token=$token",
+            "site_name" => $siteName,
+            "base_url" => $baseUrl,
+            "username" => $user["name"],
+            "valid_time" => $this->formatDuration($validHours, "hour")
+          ]
+        ]);
+        $this->lastError = $req->getLastError();
 
-        foreach($replacements as $key => $value) {
-          $messageBody = str_replace("{{{$key}}}", $value, $messageBody);
+        if ($this->success) {
+          $messageBody = $req->getResult()["html"];
+          $request = new \Api\Mail\Send($this->user);
+          $this->success = $request->execute(array(
+            "to" => $email,
+            "subject" => "[$siteName] Password Reset",
+            "body" => $messageBody
+          ));
+          $this->lastError = $request->getLastError();
         }
-
-        $request = new \Api\Mail\Send($this->user);
-        $this->success = $request->execute(array(
-          "to" => $email,
-          "subject" => "[$siteName] Password Reset",
-          "body" => $messageBody
-        ));
-        $this->lastError = $request->getLastError();
       }
 
       return $this->success;
     }
 
-    private function findUser($email) {
+    private function findUser($email): ?array {
       $sql = $this->user->getSQL();
       $res = $sql->select("User.uid", "User.name")
         ->from("User")
@@ -1082,14 +1153,12 @@ namespace Api\User {
       $this->success = ($res !== FALSE);
       $this->lastError = $sql->getLastError();
       if ($this->success) {
-        if (empty($res)) {
-          return null;
-        } else {
+        if (!empty($res)) {
           return $res[0];
         }
       }
 
-      return $this->success;
+      return null;
     }
   }
 
@@ -1125,11 +1194,6 @@ namespace Api\User {
         }
       }
 
-      $messageBody = $this->getMessageTemplate("message_confirm_email");
-      if ($messageBody === false) {
-        return false;
-      }
-
       $email = $this->getParam("email");
       $sql = $this->user->getSQL();
       $res = $sql->select("User.uid", "User.name", "UserToken.token", "UserToken.token_type", "UserToken.used")
@@ -1157,36 +1221,49 @@ namespace Api\User {
         }))
       );
 
+      $validHours = 48;
       if (!$token) {
         // no token generated yet, let's generate one
         $token = generateRandomString(36);
-        if (!$this->insertToken($userId, $token, "email_confirm", 48)) {
+        if (!$this->insertToken($userId, $token, "email_confirm", $validHours)) {
           return false;
         }
+      } else {
+        $sql->update("UserToken")
+          ->set("valid_until", (new DateTime())->modify("+$validHours hour"))
+          ->where(new Compare("token", $token))
+          ->execute();
       }
 
       $username = $res[0]["name"];
-      $baseUrl = htmlspecialchars($settings->getBaseUrl());
-      $siteName = htmlspecialchars($settings->getSiteName());
-      $replacements = array(
-        "link" => "$baseUrl/confirmEmail?token=$token",
-        "site_name" => $siteName,
-        "base_url" => $baseUrl,
-        "username" => htmlspecialchars($username)
-      );
+      $baseUrl = $settings->getBaseUrl();
+      $siteName = $settings->getSiteName();
+
+      $req = new Render($this->user);
+      $this->success = $req->execute([
+        "file" => "mail/confirm_email.twig",
+        "parameters" => [
+          "link" => "$baseUrl/confirmEmail?token=$token",
+          "site_name" => $siteName,
+          "base_url" => $baseUrl,
+          "username" => $username,
+          "valid_time" => $this->formatDuration($validHours, "hour")
+        ]
+      ]);
+      $this->lastError = $req->getLastError();
 
-      foreach($replacements as $key => $value) {
-        $messageBody = str_replace("{{{$key}}}", $value, $messageBody);
-      }
+      if ($this->success) {
+        $messageBody = $req->getResult()["html"];
+        $request = new \Api\Mail\Send($this->user);
+        $this->success = $request->execute(array(
+          "to" => $email,
+          "subject" => "[$siteName] E-Mail Confirmation",
+          "body" => $messageBody
+        ));
 
-      $request = new \Api\Mail\Send($this->user);
-      $this->success = $request->execute(array(
-        "to" => $email,
-        "subject" => "[$siteName] E-Mail Confirmation",
-        "body" => $messageBody
-      ));
+        $this->lastError = $request->getLastError();
+      }
 
-      $this->lastError = $request->getLastError();
       return $this->success;
     }
   }
@@ -1203,7 +1280,7 @@ namespace Api\User {
       $this->csrfTokenRequired = false;
     }
 
-    private function updateUser($uid, $password) {
+    private function updateUser($uid, $password): bool {
       $sql = $this->user->getSQL();
       $res = $sql->update("User")
         ->set("password", $this->hashPassword($password))
@@ -1254,7 +1331,10 @@ namespace Api\User {
     public function __construct(User $user, bool $externalCall = false) {
       parent::__construct($user, $externalCall, array(
         'username' => new StringType('username', 32, true, NULL),
+        'fullName' => new StringType('fullName', 64, true, NULL),
         'password' => new StringType('password', -1, true, NULL),
+        'confirmPassword' => new StringType('confirmPassword', -1, true, NULL),
+        'oldPassword' => new StringType('oldPassword', -1, true, NULL),
       ));
       $this->loginRequired = true;
       $this->csrfTokenRequired = true;
@@ -1267,14 +1347,17 @@ namespace Api\User {
       }
 
       $newUsername = $this->getParam("username");
+      $oldPassword = $this->getParam("oldPassword");
       $newPassword = $this->getParam("password");
+      $newPasswordConfirm = $this->getParam("confirmPassword");
+      $newFullName = $this->getParam("fullName");
 
-      if ($newUsername === null && $newPassword === null) {
-        return $this->createError("You must either provide an updated username or password");
+      if ($newUsername === null && $newPassword === null && $newPasswordConfirm === null && $newFullName === null) {
+        return $this->createError("You must either provide an updated username, fullName or password");
       }
 
       $sql = $this->user->getSQL();
-      $query = $sql->update("User")->where(new Compare("id", $this->user->getId()));
+      $query = $sql->update("User")->where(new Compare("uid", $this->user->getId()));
       if ($newUsername !== null) {
         if (!$this->checkUsernameRequirements($newUsername) || $this->userExists($newUsername)) {
           return false;
@@ -1283,10 +1366,29 @@ namespace Api\User {
         }
       }
 
-      if ($newPassword !== null) { // TODO: confirm password?
-        if (!$this->checkPasswordRequirements($newPassword, $newPassword)) {
+      if ($newFullName !== null) {
+        $query->set("fullName", $newFullName);
+      }
+
+      if ($newPassword !== null || $newPasswordConfirm !== null) {
+        if (!$this->checkPasswordRequirements($newPassword, $newPasswordConfirm)) {
           return false;
         } else {
+          $res = $sql->select("password")
+            ->from("User")
+            ->where(new Compare("uid", $this->user->getId()))
+            ->execute();
+
+          $this->success = ($res !== false);
+          $this->lastError = $sql->getLastError();
+          if (!$this->success) {
+            return false;
+          }
+
+          if (!password_verify($oldPassword, $res[0]["password"])) {
+            return $this->createError("Wrong password");
+          }
+
           $query->set("password", $this->hashPassword($newPassword));
         }
       }
@@ -1296,4 +1398,152 @@ namespace Api\User {
       return $this->success;
     }
   }
+
+  class UploadPicture extends UserAPI {
+    public function __construct(User $user, bool $externalCall = false) {
+      parent::__construct($user, $externalCall, [
+        "scale" => new Parameter("scale", Parameter::TYPE_FLOAT, true, NULL),
+      ]);
+      $this->loginRequired = true;
+      $this->forbidMethod("GET");
+    }
+
+    /**
+     * @throws ImagickException
+     */
+    protected function onTransform(\Imagick $im, $uploadDir) {
+
+      $minSize = 75;
+      $maxSize = 500;
+
+      $width = $im->getImageWidth();
+      $height = $im->getImageHeight();
+      $doResize = false;
+
+      if ($width < $minSize || $height < $minSize) {
+        if ($width < $height) {
+          $newWidth = $minSize;
+          $newHeight = intval(($minSize / $width) * $height);
+        } else {
+          $newHeight = $minSize;
+          $newWidth = intval(($minSize / $height) * $width);
+        }
+
+        $doResize = true;
+      } else if ($width > $maxSize || $height > $maxSize) {
+        if ($width > $height) {
+          $newWidth = $maxSize;
+          $newHeight = intval($height * ($maxSize / $width));
+        } else {
+          $newHeight = $maxSize;
+          $newWidth = intval($width * ($maxSize / $height));
+        }
+
+        $doResize = true;
+      } else {
+        $newWidth = $width;
+        $newHeight = $height;
+      }
+
+      if ($width < $minSize || $height < $minSize) {
+        return $this->createError("Error processing image. Bad dimensions.");
+      }
+
+      if ($doResize) {
+        $width = $newWidth;
+        $height = $newHeight;
+        $im->resizeImage($width, $height, \Imagick::FILTER_SINC, 1);
+      }
+
+      $size = $this->getParam("size");
+      if (is_null($size)) {
+        $size = min($width, $height);
+      }
+
+      $offset = [$this->getParam("offsetX"), $this->getParam("offsetY")];
+      if ($size < $minSize or $size > $maxSize) {
+        return $this->createError("Invalid size. Must be in range of $minSize-$maxSize.");
+      }/* else if ($offset[0] < 0 || $offset[1] < 0 || $offset[0]+$size > $width ||  $offset[1]+$size > $height) {
+        return $this->createError("Offsets out of bounds.");
+      }*/
+
+      if ($offset[0] !== 0 || $offset[1] !== 0 || $size !== $width || $size !== $height) {
+        $im->cropImage($size, $size, $offset[0], $offset[1]);
+      }
+
+      $fileName = uuidv4() . ".jpg";
+      $im->writeImage("$uploadDir/$fileName");
+      $im->destroy();
+      return $fileName;
+    }
+
+    public function execute($values = array()): bool {
+      if (!parent::execute($values)) {
+        return false;
+      }
+
+      $userId = $this->user->getId();
+      $uploadDir = WEBROOT . "/img/uploads/user/$userId";
+      list ($fileName, $imageName) = $this->processImageUpload($uploadDir, ["png","jpg","jpeg"], "onTransform");
+      if (!$this->success) {
+        return false;
+      }
+
+      $oldPfp = $this->user->getProfilePicture();
+      if ($oldPfp) {
+        $path = "$uploadDir/$oldPfp";
+        if (is_file($path)) {
+          @unlink($path);
+        }
+      }
+
+      $sql = $this->user->getSQL();
+      $this->success = $sql->update("User")
+        ->set("profilePicture", $fileName)
+        ->where(new Compare("uid", $userId))
+        ->execute();
+
+      $this->lastError = $sql->getLastError();
+      if ($this->success) {
+        $this->result["profilePicture"] = $fileName;
+      }
+
+      return $this->success;
+    }
+  }
+
+  class RemovePicture extends UserAPI {
+    public function __construct(User $user, bool $externalCall = false) {
+      parent::__construct($user, $externalCall, []);
+      $this->loginRequired = true;
+    }
+
+    public function execute($values = array()): bool {
+      if (!parent::execute($values)) {
+        return false;
+      }
+
+      $pfp = $this->user->getProfilePicture();
+      if (!$pfp) {
+        return $this->createError("You did not upload a profile picture yet");
+      }
+
+      $userId = $this->user->getId();
+      $sql = $this->user->getSQL();
+      $this->success = $sql->update("User")
+        ->set("profilePicture", NULL)
+        ->where(new Compare("uid", $userId))
+        ->execute();
+      $this->lastError = $sql->getLastError();
+
+      if ($this->success) {
+        $path = WEBROOT . "/img/uploads/user/$userId/$pfp";
+        if (is_file($path)) {
+          @unlink($path);
+        }
+      }
+
+      return $this->success;
+    }
+  }
 }

+ 1 - 0
core/Configuration/Settings.class.php

@@ -57,6 +57,7 @@ class Settings {
     if ($success) {
       $result = $req->getResult()["settings"];
       $this->siteName = $result["site_name"] ?? $this->siteName;
+      $this->baseUrl = $result["base_url"] ?? $this->baseUrl;
       $this->registrationAllowed = $result["user_registration_enabled"] ?? $this->registrationAllowed;
       $this->installationComplete = $result["installation_completed"] ?? $this->installationComplete;
       $this->jwtSecret = $result["jwt_secret"] ?? $this->jwtSecret;

+ 49 - 61
core/Documents/Account.class.php

@@ -1,74 +1,62 @@
 <?php
 
-namespace Documents {
 
-  use Documents\Account\AccountBody;
-  use Documents\Account\AccountHead;
-  use Elements\Document;
-  use Objects\User;
+namespace Documents;
 
-  class Account extends Document {
-    public function __construct(User $user, ?string $view) {
-      parent::__construct($user, AccountHead::class, AccountBody::class, $view);
-    }
-  }
-}
-
-namespace Documents\Account {
-
-  use Elements\Head;
-  use Elements\Link;
-  use Elements\Script;
-  use Elements\SimpleBody;
-
-  class AccountHead extends Head {
-
-    public function __construct($document) {
-      parent::__construct($document);
-    }
-
-    protected function initSources() {
-      $this->loadJQuery();
-      $this->addJS(Script::CORE);
-      $this->addJS(Script::ACCOUNT);
-      $this->loadBootstrap();
-      $this->loadFontawesome();
-      $this->addCSS(Link::CORE);
-    }
-
-    protected function initMetas(): array {
-      return array(
-        array('name' => 'viewport', 'content' => 'width=device-width, initial-scale=1.0'),
-        array('name' => 'format-detection', 'content' => 'telephone=yes'),
-        array('charset' => 'utf-8'),
-        array("http-equiv" => 'expires', 'content' => '0'),
-        array("name" => 'robots', 'content' => 'noarchive'),
-      );
-    }
+use Elements\TemplateDocument;
+use Objects\User;
 
-    protected function initRawFields(): array {
-      return array();
-    }
 
-    protected function initTitle(): string {
-      return "Account";
-    }
+class Account extends TemplateDocument {
+  public function __construct(User $user, ?string $template) {
+    parent::__construct($user, $template);
+    $this->enableCSP();
   }
 
-  class AccountBody extends SimpleBody {
-
-    public function __construct($document) {
-      parent::__construct($document);
-    }
-
-    protected function getContent(): string {
+  private function createError(string $message) {
+    $this->parameters["view"]["success"] = false;
+    $this->parameters["view"]["message"] = $message;
+  }
 
-      $view = $this->getDocument()->getView();
-      if ($view === null) {
-        return "The page you does not exist or is no longer valid. <a href='/'>Return to start page</a>";
+  protected function loadParameters() {
+    $this->parameters["view"] = ["success" => true];
+    if ($this->getTemplateName() === "account/reset_password.twig") {
+      if (isset($_GET["token"]) && is_string($_GET["token"]) && !empty($_GET["token"])) {
+        $this->parameters["view"]["token"] = $_GET["token"];
+        $req = new \Api\User\CheckToken($this->getUser());
+        $this->parameters["view"]["success"] = $req->execute(array("token" => $_GET["token"]));
+        if ($this->parameters["view"]["success"]) {
+          if (strcmp($req->getResult()["token"]["type"], "password_reset") !== 0) {
+            $this->createError("The given token has a wrong type.");
+          }
+        } else {
+          $this->createError("Error requesting password reset: " . $req->getLastError());
+        }
+      }
+    } else if ($this->getTemplateName() === "account/register.twig") {
+      $settings = $this->user->getConfiguration()->getSettings();
+      if ($this->user->isLoggedIn()) {
+        $this->createError("You are already logged in.");
+      } else if (!$settings->isRegistrationAllowed()) {
+        $this->createError("Registration is not enabled on this website.");
+      }
+    } else if ($this->getTemplateName() === "account/accept_invite.twig") {
+      if (isset($_GET["token"]) && is_string($_GET["token"]) && !empty($_GET["token"])) {
+        $this->parameters["view"]["token"] = $_GET["token"];
+        $req = new \Api\User\CheckToken($this->getUser());
+        $this->parameters["view"]["success"] = $req->execute(array("token" => $_GET["token"]));
+        if ($this->parameters["view"]["success"]) {
+          if (strcmp($req->getResult()["token"]["type"], "invite") !== 0) {
+            $this->createError("The given token has a wrong type.");
+          } else {
+            $this->parameters["view"]["invited_user"] = $req->getResult()["user"];
+          }
+        } else {
+          $this->createError("Error confirming e-mail address: " . $req->getLastError());
+        }
+      } else {
+        $this->createError("The link you visited is no longer valid");
       }
-
-      return $view->getCode();
     }
   }
 }

+ 9 - 45
core/Documents/Admin.class.php

@@ -1,51 +1,15 @@
 <?php
 
-namespace Documents {
+namespace Documents;
 
-  use Documents\Admin\AdminHead;
-  use Elements\Document;
-  use Objects\User;
-  use Views\Admin\AdminDashboardBody;
-  use Views\Admin\LoginBody;
+use Elements\TemplateDocument;
+use Objects\User;
 
-  class Admin extends Document {
-    public function __construct(User $user, ?string $view = NULL) {
-      $body = $user->isLoggedIn() ? AdminDashboardBody::class : LoginBody::class;
-      parent::__construct($user, AdminHead::class, $body, $view);
-    }
-  }
-}
-
-namespace Documents\Admin {
-
-  use Elements\Head;
-
-  class AdminHead extends Head {
-
-    public function __construct($document) {
-      parent::__construct($document);
-    }
-
-    protected function initSources() {
-      $this->loadFontawesome();
-    }
-
-    protected function initMetas(): array {
-      return array(
-        array('name' => 'viewport', 'content' => 'width=device-width, initial-scale=1.0'),
-        array('name' => 'format-detection', 'content' => 'telephone=yes'),
-        array('charset' => 'utf-8'),
-        array("http-equiv" => 'expires', 'content' => '0'),
-        array("name" => 'robots', 'content' => 'noarchive'),
-      );
-    }
-
-    protected function initRawFields(): array {
-      return array();
-    }
-
-    protected function initTitle(): string {
-      return $this->getSiteName() . " - Administration";
-    }
+class Admin extends TemplateDocument {
+  public function __construct(User $user) {
+    $template = $user->isLoggedIn() ? "admin.twig" : "redirect.twig";
+    $params = $user->isLoggedIn() ? [] : ["url" => "/login"];
+    parent::__construct($user, $template, $params);
+    $this->enableCSP();
   }
 }

+ 9 - 55
core/Documents/Document404.class.php

@@ -1,64 +1,18 @@
 <?php
 
-namespace Documents {
+namespace Documents;
 
-  use Documents\Document404\Body404;
-  use Documents\Document404\Head404;
-  use Elements\Document;
+use Elements\TemplateDocument;
+use Objects\User;
 
-  class Document404 extends Document {
-    public function __construct($user, ?string $view = NULL) {
-      parent::__construct($user, Head404::class, Body404::class, $view);
-    }
-  }
-}
-
-namespace Documents\Document404 {
-
-  use Elements\Head;
-  use Elements\SimpleBody;
-  use Views\View404;
-
-  class Head404 extends Head {
-
-    public function __construct($document) {
-      parent::__construct($document);
-    }
+class Document404 extends TemplateDocument {
 
-    protected function initSources() {
-    }
-
-    protected function initMetas(): array {
-      return array(
-        array('name' => 'viewport', 'content' => 'width=device-width, initial-scale=1.0'),
-        array('name' => 'format-detection', 'content' => 'telephone=yes'),
-        array('charset' => 'utf-8'),
-        array("http-equiv" => 'expires', 'content' => '0'),
-        array("name" => 'robots', 'content' => 'noarchive'),
-      );
-    }
-
-    protected function initRawFields(): array {
-      return array();
-    }
-
-    protected function initTitle(): string {
-      return "WebBase - Not Found";
-    }
+  public function __construct(User $user) {
+    parent::__construct($user, "404.twig");
   }
 
-  class Body404 extends SimpleBody {
-
-    public function __construct($document) {
-      parent::__construct($document);
-    }
-
-    public function loadView() {
-      http_response_code(404);
-    }
-
-    protected function getContent(): string {
-      return $this->load(View404::class);
-    }
+  public function loadParameters() {
+    parent::loadParameters();
+    http_response_code(404);
   }
 }

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

@@ -4,9 +4,9 @@ namespace Documents {
 
   use Documents\Install\InstallBody;
   use Documents\Install\InstallHead;
-  use Elements\Document;
+  use Elements\HtmlDocument;
 
-  class Install extends Document {
+  class Install extends HtmlDocument {
     public function __construct($user) {
       parent::__construct($user, InstallHead::class, InstallBody::class);
       $this->databaseRequired = false;

+ 13 - 0
core/Driver/SQL/Column/BigIntColumn.php

@@ -0,0 +1,13 @@
+<?php
+
+namespace Driver\SQL\Query;
+
+use Driver\SQL\Column\IntColumn;
+
+class BigIntColumn extends IntColumn {
+
+  public function __construct(string $name, bool $nullable, $defaultValue, bool $unsigned) {
+    parent::__construct($name, $nullable, $defaultValue, $unsigned);
+    $this->type = "BIGINT";
+  }
+}

+ 4 - 0
core/Driver/SQL/Column/EnumColumn.class.php

@@ -11,5 +11,9 @@ class EnumColumn extends Column {
     $this->values = $values;
   }
 
+  public function addValues(string $value) {
+    $this->values[] = $value;
+  }
+
   public function getValues(): array { return $this->values; }
 }

+ 13 - 1
core/Driver/SQL/Column/IntColumn.class.php

@@ -4,8 +4,20 @@ namespace Driver\SQL\Column;
 
 class IntColumn extends Column {
 
-  public function __construct(string $name, bool $nullable = false, $defaultValue = NULL) {
+  protected string $type;
+  private bool $unsigned;
+
+  public function __construct(string $name, bool $nullable = false, $defaultValue = NULL, bool $unsigned = false) {
     parent::__construct($name, $nullable, $defaultValue);
+    $this->type = "INTEGER";
+    $this->unsigned = $unsigned;
+  }
+
+  public function isUnsigned(): bool {
+    return $this->unsigned;
   }
 
+  public function getType(): string {
+    return $this->type;
+  }
 }

+ 7 - 7
core/Driver/SQL/Condition/CondIn.class.php

@@ -4,14 +4,14 @@ namespace Driver\SQL\Condition;
 
 class CondIn extends Condition {
 
-  private string $column;
-  private $expression;
+  private $needle;
+  private $haystack;
 
-  public function __construct(string $column, $expression) {
-    $this->column = $column;
-    $this->expression = $expression;
+  public function __construct($needle, $haystack) {
+    $this->needle = $needle;
+    $this->haystack = $haystack;
   }
 
-  public function getColumn(): string { return $this->column; }
-  public function getExpression() { return $this->expression; }
+  public function getNeedle() { return $this->needle; }
+  public function getHaystack() { return $this->haystack; }
 }

+ 18 - 0
core/Driver/SQL/Expression/JsonArrayAgg.class.php

@@ -0,0 +1,18 @@
+<?php
+
+namespace Driver\SQL\Expression;
+
+class JsonArrayAgg extends Expression {
+
+  private $value;
+  private string $alias;
+
+  public function __construct($value, string $alias) {
+    $this->value = $value;
+    $this->alias = $alias;
+  }
+
+  public function getValue() { return $this->value; }
+  public function getAlias(): string { return $this->alias; }
+
+}

+ 0 - 2
core/Driver/SQL/Expression/Sum.class.php

@@ -2,8 +2,6 @@
 
 namespace Driver\SQL\Expression;
 
-use Driver\SQL\Condition\Condition;
-
 class Sum extends Expression {
 
   private $value;

+ 7 - 2
core/Driver/SQL/MySQL.class.php

@@ -14,12 +14,12 @@ use \Driver\SQL\Column\DateTimeColumn;
 use Driver\SQL\Column\BoolColumn;
 use Driver\SQL\Column\JsonColumn;
 
-use Driver\SQL\Condition\CondRegex;
 use Driver\SQL\Expression\Add;
 use Driver\SQL\Expression\CurrentTimeStamp;
 use Driver\SQL\Expression\DateAdd;
 use Driver\SQL\Expression\DateSub;
 use Driver\SQL\Expression\Expression;
+use Driver\SQL\Expression\JsonArrayAgg;
 use Driver\SQL\Query\CreateProcedure;
 use Driver\SQL\Query\CreateTrigger;
 use Driver\SQL\Query\Query;
@@ -228,7 +228,8 @@ class MySQL extends SQL {
     } else if($column instanceof SerialColumn) {
       return "INTEGER AUTO_INCREMENT";
     } else if($column instanceof IntColumn) {
-      return "INTEGER";
+      $unsigned = $column->isUnsigned() ? " UNSIGNED" : "";
+      return $column->getType() . $unsigned;
     } else if($column instanceof DateTimeColumn) {
       return "DATETIME";
     } else if($column instanceof BoolColumn) {
@@ -416,6 +417,10 @@ class MySQL extends SQL {
       return "$dateFunction($lhs, INTERVAL $rhs $unit)";
     } else if ($exp instanceof CurrentTimeStamp) {
       return "NOW()";
+    } else if ($exp instanceof JsonArrayAgg) {
+      $value = $this->addValue($exp->getValue(), $params);
+      $alias = $this->columnName($exp->getAlias());
+      return "JSON_ARRAYAGG($value) as $alias";
     } else {
       return parent::createExpression($exp, $params);
     }

+ 6 - 1
core/Driver/SQL/PostgreSQL.class.php

@@ -20,6 +20,7 @@ use Driver\SQL\Expression\CurrentTimeStamp;
 use Driver\SQL\Expression\DateAdd;
 use Driver\SQL\Expression\DateSub;
 use Driver\SQL\Expression\Expression;
+use Driver\SQL\Expression\JsonArrayAgg;
 use Driver\SQL\Query\CreateProcedure;
 use Driver\SQL\Query\CreateTrigger;
 use Driver\SQL\Query\Insert;
@@ -219,7 +220,7 @@ class PostgreSQL extends SQL {
     } else if($column instanceof SerialColumn) {
       return "SERIAL";
     } else if($column instanceof IntColumn) {
-      return "INTEGER";
+      return $column->getType();
     } else if($column instanceof DateTimeColumn) {
       return "TIMESTAMP";
     } else if($column instanceof EnumColumn) {
@@ -439,6 +440,10 @@ class PostgreSQL extends SQL {
       return "$lhs $operator $rhs";
     } else if ($exp instanceof CurrentTimeStamp) {
       return "CURRENT_TIMESTAMP";
+    } else if ($exp instanceof JsonArrayAgg) {
+      $value = $this->addValue($exp->getValue(), $params);
+      $alias = $this->columnName($exp->getAlias());
+      return "JSON_AGG($value) as $alias";
     } else {
       return parent::createExpression($exp, $params);
     }

+ 20 - 0
core/Driver/SQL/Query/AlterTable.class.php

@@ -3,15 +3,19 @@
 namespace Driver\SQL\Query;
 
 use Driver\SQL\Column\Column;
+use Driver\SQL\Column\EnumColumn;
 use Driver\SQL\Constraint\Constraint;
 use Driver\SQL\Constraint\ForeignKey;
 use Driver\SQL\Constraint\PrimaryKey;
+use Driver\SQL\MySQL;
+use Driver\SQL\PostgreSQL;
 use Driver\SQL\SQL;
 
 class AlterTable extends Query {
 
   private string $table;
   private string $action;
+  private $data;
 
   private ?Column $column;
   private ?Constraint $constraint;
@@ -59,6 +63,13 @@ class AlterTable extends Query {
     return $this;
   }
 
+  public function addToEnum(EnumColumn $column, string $newValue): AlterTable {
+    $this->action = "MODIFY";
+    $this->column = $column;
+    $this->data = $newValue;
+    return $this;
+  }
+
   public function getAction(): string { return $this->action; }
   public function getColumn(): ?Column { return $this->column; }
   public function getConstraint(): ?Constraint { return $this->constraint; }
@@ -82,6 +93,15 @@ class AlterTable extends Query {
         $query .= $this->sql->columnName($column->getName());
       } else {
         // ADD or modify
+        if ($column instanceof EnumColumn) {
+          if ($this->sql instanceof PostgreSQL) {
+            $typeName = $this->sql->getColumnType($column);
+            $value = $this->sql->addValue($this->data, $params);
+            return "ALTER TYPE $typeName ADD VALUE $value";
+          }
+          $column->addValue($this->data);
+        }
+
         $query .= $this->sql->getColumnDefinition($column);
       }
     } else if ($constraint) {

+ 7 - 2
core/Driver/SQL/Query/CreateTable.class.php

@@ -46,8 +46,13 @@ class CreateTable extends Query {
     return $this;
   }
 
-  public function addInt(string $name, bool $nullable = false, $defaultValue = NULL): CreateTable {
-    $this->columns[$name] = new IntColumn($name, $nullable, $defaultValue);
+  public function addInt(string $name, bool $nullable = false, $defaultValue = NULL, bool $unsigned = false): CreateTable {
+    $this->columns[$name] = new IntColumn($name, $nullable, $defaultValue, $unsigned);
+    return $this;
+  }
+
+  public function addBigInt(string $name, bool $nullable = false, $defaultValue = NULL, bool $unsigned = false): CreateTable {
+    $this->columns[$name] = new BigIntColumn($name, $nullable, $defaultValue, $unsigned);
     return $this;
   }
 

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

@@ -278,22 +278,29 @@ abstract class SQL {
       }
     } else if($condition instanceof CondIn) {
 
-      $expression = $condition->getExpression();
-      if (is_array($expression)) {
+      $needle = $condition->getNeedle();
+      $haystack = $condition->getHaystack();
+      if (is_array($haystack)) {
         $values = array();
-        foreach ($expression as $value) {
+        foreach ($haystack as $value) {
           $values[] = $this->addValue($value, $params);
         }
 
         $values = implode(",", $values);
-      } else if($expression instanceof Select) {
-        $values = $expression->build($params);
+      } else if($haystack instanceof Select) {
+        $values = $haystack->build($params);
       } else {
         $this->lastError = "Unsupported in-expression value: " . get_class($condition);
         return false;
       }
 
-      return $this->columnName($condition->getColumn()) . " IN ($values)";
+      if ($needle instanceof Column) {
+        $lhs = $this->createExpression($needle, $params);
+      } else {
+        $lhs = $this->addValue($needle, $params);
+      }
+
+      return "$lhs IN ($values)";
     } else if($condition instanceof CondKeyword) {
       $left = $condition->getLeftExp();
       $right = $condition->getRightExp();
@@ -315,14 +322,14 @@ abstract class SQL {
     } else if ($condition instanceof Exists) {
         return "EXISTS(" .$condition->getSubQuery()->build($params) . ")";
     } else {
-      $this->lastError = "Unsupported condition type: " . get_class($condition);
+      $this->lastError = "Unsupported condition type: " . gettype($condition);
       return null;
     }
   }
 
   protected function createExpression(Expression $exp, array &$params): ?string {
     if ($exp instanceof Column) {
-      return $this->columnName($exp);
+      return $this->columnName($exp->getName());
     } else if ($exp instanceof Query) {
       return "(" . $exp->build($params) . ")";
     } else if ($exp instanceof CaseWhen) {
@@ -335,7 +342,7 @@ abstract class SQL {
       return "CASE WHEN $condition THEN $trueCase ELSE $falseCase END";
     } else if ($exp instanceof Sum) {
       $value = $this->addValue($exp->getValue(), $params);
-      $alias = $exp->getAlias();
+      $alias = $this->columnName($exp->getAlias());
       return "SUM($value) AS $alias";
     } else {
       $this->lastError = "Unsupported expression type: " . get_class($exp);

+ 32 - 37
core/Elements/Document.class.php

@@ -2,69 +2,64 @@
 
 namespace Elements;
 
+use Driver\SQL\SQL;
 use Objects\User;
 
 abstract class Document {
 
-  protected Head $head;
-  protected Body $body;
   protected User $user;
   protected bool $databaseRequired;
-  private ?string $activeView;
+  private bool $cspEnabled;
+  private ?string $cspNonce;
 
-  public function __construct(User $user, $headClass, $bodyClass, ?string $view = NULL) {
+  public function __construct(User $user) {
     $this->user = $user;
-    $this->head = new $headClass($this);
-    $this->body = new $bodyClass($this);
+    $this->cspEnabled = false;
+    $this->cspNonce = null;
     $this->databaseRequired = true;
-    $this->activeView = $view;
   }
 
-  public function getHead(): Head { return $this->head; }
-  public function getBody(): Body { return $this->body; }
-  public function getSQL(): ?\Driver\SQL\SQL { return $this->user->getSQL(); }
-  public function getUser(): User { return $this->user; }
-
-  public function getView() : ?View {
-
-    if ($this->activeView === null) {
-      return null;
-    }
+  public function getSQL(): ?SQL {
+    return $this->user->getSQL();
+  }
 
-    $view = parseClass($this->activeView);
-    $file = getClassPath($view);
-    if(!file_exists($file) || !is_subclass_of($view, View::class)) {
-      return null;
-    }
+  public function getUser(): User {
+    return $this->user;
+  }
 
-    return new $view($this);
+  public function getCSPNonce(): ?string {
+    return $this->cspNonce;
   }
 
-  public function getRequestedView(): string {
-    return $this->activeView;
+  public function isCSPEnabled(): bool {
+    return $this->cspEnabled;
   }
 
-  function getCode(): string {
+  public function enableCSP() {
+    $this->cspEnabled = true;
+    $this->cspNonce = generateRandomString(16, "base62");
+  }
 
+  public function getCode(): string {
     if ($this->databaseRequired) {
       $sql = $this->user->getSQL();
       if (is_null($sql)) {
         die("Database is not configured yet.");
-      } else if(!$sql->isConnected()) {
+      } else if (!$sql->isConnected()) {
         die("Database is not connected: " . $sql->getLastError());
       }
     }
 
-    $body = $this->body->getCode();
-    $head = $this->head->getCode();
-    $lang = $this->user->getLanguage()->getShortCode();
+    if ($this->cspEnabled) {
+      $csp = ["default-src 'self'", "object-src 'none'", "base-uri 'self'", "style-src 'self' 'unsafe-inline'", "script-src 'nonce-$this->cspNonce'"];
+      if ($this->user->getConfiguration()->getSettings()->isRecaptchaEnabled()) {
+        $csp[] = "frame-src https://www.google.com/ 'self'";
+      }
 
-    $html = "<!DOCTYPE html>";
-    $html .= "<html lang=\"$lang\">";
-    $html .= $head;
-    $html .= $body;
-    $html .= "</html>";
-    return $html;
-  }
+      $compiledCSP = implode(";", $csp);
+      header("Content-Security-Policy: $compiledCSP;");
+    }
 
+    return "";
+  }
 }

+ 79 - 0
core/Elements/HtmlDocument.class.php

@@ -0,0 +1,79 @@
+<?php
+
+namespace Elements;
+
+use Objects\User;
+
+class HtmlDocument extends Document {
+
+  protected Head $head;
+  protected Body $body;
+  private ?string $activeView;
+
+  public function __construct(User $user, $headClass, $bodyClass, ?string $view = NULL) {
+    parent::__construct($user);
+    $this->head = $headClass ? new $headClass($this) : null;
+    $this->body = $bodyClass ? new $bodyClass($this) : null;
+    $this->activeView = $view;
+  }
+
+  public function getHead(): Head { return $this->head; }
+  public function getBody(): Body { return $this->body; }
+
+  public function getView() : ?View {
+
+    if ($this->activeView === null) {
+      return null;
+    }
+
+    $view = parseClass($this->activeView);
+    $file = getClassPath($view);
+    if(!file_exists($file) || !is_subclass_of($view, View::class)) {
+      return null;
+    }
+
+    return new $view($this);
+  }
+
+  public function createScript($type, $src, $content = ""): Script {
+    $script = new Script($type, $src, $content);
+
+    if ($this->isCSPEnabled()) {
+      $script->setNonce($this->getCSPNonce());
+    }
+
+    return $script;
+  }
+
+  public function getRequestedView(): string {
+    return $this->activeView;
+  }
+
+  function getCode(): string {
+
+    parent::getCode();
+
+    // generate body first, so we can modify head
+    $body = $this->body->getCode();
+
+    if ($this->isCSPEnabled()) {
+      foreach ($this->head->getSources() as $element) {
+        if ($element instanceof Script || $element instanceof Link) {
+          $element->setNonce($this->getCSPNonce());
+        }
+      }
+    }
+
+    $head = $this->head->getCode();
+    $lang = $this->user->getLanguage()->getShortCode();
+
+    $html = "<!DOCTYPE html>";
+    $html .= "<html lang=\"$lang\">";
+    $html .= $head;
+    $html .= $body;
+    $html .= "</html>";
+    return $html;
+  }
+
+
+}

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

@@ -15,15 +15,30 @@ class Link extends StaticView {
   private string $type;
   private string $rel;
   private string $href;
+  private ?string $nonce;
 
   function __construct($rel, $href, $type = "") {
     $this->href = $href;
     $this->type = $type;
     $this->rel = $rel;
+    $this->nonce = null;
   }
 
   function getCode(): string {
-    $type = (empty($this->type) ? "" : " type=\"$this->type\"");
-    return "<link rel=\"$this->rel\" href=\"$this->href\"$type/>";
+    $attributes = ["rel" => $this->rel, "href" => $this->href];
+
+    if (!empty($this->type)) {
+      $attributes["type"] = $this->type;
+    }
+    if (!empty($this->nonce)) {
+      $attributes["nonce"] = $this->nonce;
+    }
+
+    $attributes = html_attributes($attributes);
+    return "<link $attributes/>";
+  }
+
+  public function setNonce(string $nonce) {
+    $this->nonce = $nonce;
   }
 }

+ 17 - 3
core/Elements/Script.class.php

@@ -11,21 +11,35 @@ class Script extends StaticView {
   const INSTALL   = "/js/install.js";
   const BOOTSTRAP = "/js/bootstrap.bundle.min.js";
   const ACCOUNT   = "/js/account.js";
-  const SECLAB    = "/js/seclab.min.js";
   const FONTAWESOME = "/js/fontawesome-all.min.js";
 
   private string $type;
   private string $content;
   private string $src;
+  private ?string $nonce;
 
   function __construct($type, $src, $content = "") {
     $this->src = $src;
     $this->type = $type;
     $this->content = $content;
+    $this->nonce = null;
   }
 
   function getCode(): string {
-    $src = (empty($this->src) ? "" : " src=\"$this->src\"");
-      return "<script type=\"$this->type\"$src>$this->content</script>";
+    $attributes = ["type" => $this->type];
+    if (!empty($this->src)) {
+      $attributes["src"] = $this->src;
+    }
+
+    if (!empty($this->nonce)) {
+      $attributes["nonce"] = $this->nonce;
+    }
+
+    $attributes = html_attributes($attributes);
+    return "<script $attributes>$this->content</script>";
+  }
+
+  public function setNonce(string $nonce) {
+    $this->nonce = $nonce;
   }
 }

+ 71 - 0
core/Elements/TemplateDocument.class.php

@@ -0,0 +1,71 @@
+<?php
+
+namespace Elements;
+
+use Objects\User;
+use Twig\Environment;
+use Twig\Error\LoaderError;
+use Twig\Error\RuntimeError;
+use Twig\Error\SyntaxError;
+use Twig\Loader\FilesystemLoader;
+
+class TemplateDocument extends Document {
+
+  private string $templateName;
+  protected array $parameters;
+  private Environment $twigEnvironment;
+  private FilesystemLoader $twigLoader;
+
+  public function __construct(User $user, string $templateName, array $initialParameters = []) {
+    parent::__construct($user);
+    $this->templateName = $templateName;
+    $this->parameters = $initialParameters;
+    $this->twigLoader = new FilesystemLoader(WEBROOT . '/core/Templates');
+    $this->twigEnvironment = new Environment($this->twigLoader, [
+      'cache' => WEBROOT . '/core/TemplateCache',
+      'auto_reload' => true
+    ]);
+  }
+
+  protected function getTemplateName(): string {
+    return $this->templateName;
+  }
+
+  protected function loadParameters() {
+
+  }
+
+  public function getCode(): string {
+    parent::getCode();
+    $this->loadParameters();
+    return $this->renderTemplate($this->templateName, $this->parameters);
+  }
+
+  public function renderTemplate(string $name, array $params = []): string {
+    try {
+
+      $params["user"] = [
+        "lang" => $this->user->getLanguage()->getShortCode(),
+        "loggedIn" => $this->user->isLoggedIn(),
+      ];
+
+      $settings = $this->user->getConfiguration()->getSettings();
+      $params["site"] = [
+        "name" => $settings->getSiteName(),
+        "baseUrl" => $settings->getBaseUrl(),
+        "recaptcha" => [
+          "key" => $settings->isRecaptchaEnabled() ? $settings->getRecaptchaSiteKey() : null,
+          "enabled" => $settings->isRecaptchaEnabled(),
+        ],
+        "csp" => [
+          "nonce" => $this->getCSPNonce(),
+          "enabled" => $this->isCSPEnabled()
+        ]
+      ];
+
+      return $this->twigEnvironment->render($name, $params);
+    } catch (LoaderError | RuntimeError | SyntaxError $e) {
+      return "<b>Error rendering twig template: " . htmlspecialchars($e->getMessage()) . "</b>";
+    }
+  }
+}

+ 1 - 0
core/External/.gitignore

@@ -0,0 +1 @@
+vendor/

+ 3 - 2
core/External/PHPMailer/Exception.php

@@ -1,4 +1,5 @@
 <?php
+
 /**
  * PHPMailer Exception class.
  * PHP Version 5.5.
@@ -9,7 +10,7 @@
  * @author    Jim Jagielski (jimjag) <jimjag@gmail.com>
  * @author    Andy Prevost (codeworxtech) <codeworxtech@users.sourceforge.net>
  * @author    Brent R. Matzelle (original founder)
- * @copyright 2012 - 2017 Marcus Bointon
+ * @copyright 2012 - 2020 Marcus Bointon
  * @copyright 2010 - 2012 Jim Jagielski
  * @copyright 2004 - 2009 Andy Prevost
  * @license   http://www.gnu.org/copyleft/lesser.html GNU Lesser General Public License
@@ -34,6 +35,6 @@ class Exception extends \Exception
      */
     public function errorMessage()
     {
-        return '<strong>' . htmlspecialchars($this->getMessage()) . "</strong><br />\n";
+        return '<strong>' . htmlspecialchars($this->getMessage(), ENT_COMPAT | ENT_HTML401) . "</strong><br />\n";
     }
 }

+ 3 - 2
core/External/PHPMailer/OAuth.php

@@ -1,4 +1,5 @@
 <?php
+
 /**
  * PHPMailer - PHP email creation and transport class.
  * PHP Version 5.5.
@@ -9,7 +10,7 @@
  * @author    Jim Jagielski (jimjag) <jimjag@gmail.com>
  * @author    Andy Prevost (codeworxtech) <codeworxtech@users.sourceforge.net>
  * @author    Brent R. Matzelle (original founder)
- * @copyright 2012 - 2015 Marcus Bointon
+ * @copyright 2012 - 2020 Marcus Bointon
  * @copyright 2010 - 2012 Jim Jagielski
  * @copyright 2004 - 2009 Andy Prevost
  * @license   http://www.gnu.org/copyleft/lesser.html GNU Lesser General Public License
@@ -122,7 +123,7 @@ class OAuth
      */
     public function getOauth64()
     {
-        // Get a new token if it's not available or has expired
+        //Get a new token if it's not available or has expired
         if (null === $this->oauthToken || $this->oauthToken->hasExpired()) {
             $this->oauthToken = $this->getToken();
         }

File diff suppressed because it is too large
+ 340 - 135
core/External/PHPMailer/PHPMailer.php


+ 72 - 29
core/External/PHPMailer/POP3.php

@@ -1,4 +1,5 @@
 <?php
+
 /**
  * PHPMailer POP-Before-SMTP Authentication Class.
  * PHP Version 5.5.
@@ -9,7 +10,7 @@
  * @author    Jim Jagielski (jimjag) <jimjag@gmail.com>
  * @author    Andy Prevost (codeworxtech) <codeworxtech@users.sourceforge.net>
  * @author    Brent R. Matzelle (original founder)
- * @copyright 2012 - 2019 Marcus Bointon
+ * @copyright 2012 - 2020 Marcus Bointon
  * @copyright 2010 - 2012 Jim Jagielski
  * @copyright 2004 - 2009 Andy Prevost
  * @license   http://www.gnu.org/copyleft/lesser.html GNU Lesser General Public License
@@ -45,7 +46,7 @@ class POP3
      *
      * @var string
      */
-    const VERSION = '6.1.4';
+    const VERSION = '6.5.1';
 
     /**
      * Default POP3 port number.
@@ -62,12 +63,16 @@ class POP3
     const DEFAULT_TIMEOUT = 30;
 
     /**
-     * Debug display level.
-     * Options: 0 = no, 1+ = yes.
+     * POP3 class debug output mode.
+     * Debug output level.
+     * Options:
+     * @see POP3::DEBUG_OFF: No output
+     * @see POP3::DEBUG_SERVER: Server messages, connection/server errors
+     * @see POP3::DEBUG_CLIENT: Client and Server messages, connection/server errors
      *
      * @var int
      */
-    public $do_debug = 0;
+    public $do_debug = self::DEBUG_OFF;
 
     /**
      * POP3 mail server hostname.
@@ -130,6 +135,28 @@ class POP3
      */
     const LE = "\r\n";
 
+    /**
+     * Debug level for no output.
+     *
+     * @var int
+     */
+    const DEBUG_OFF = 0;
+
+    /**
+     * Debug level to show server -> client messages
+     * also shows clients connection errors or errors from server
+     *
+     * @var int
+     */
+    const DEBUG_SERVER = 1;
+
+    /**
+     * Debug level to show client -> server and server -> client messages.
+     *
+     * @var int
+     */
+    const DEBUG_CLIENT = 2;
+
     /**
      * Simple static wrapper for all-in-one POP before SMTP.
      *
@@ -172,13 +199,13 @@ class POP3
     public function authorise($host, $port = false, $timeout = false, $username = '', $password = '', $debug_level = 0)
     {
         $this->host = $host;
-        // If no port value provided, use default
+        //If no port value provided, use default
         if (false === $port) {
             $this->port = static::DEFAULT_PORT;
         } else {
             $this->port = (int) $port;
         }
-        // If no timeout value provided, use default
+        //If no timeout value provided, use default
         if (false === $timeout) {
             $this->tval = static::DEFAULT_TIMEOUT;
         } else {
@@ -187,9 +214,9 @@ class POP3
         $this->do_debug = $debug_level;
         $this->username = $username;
         $this->password = $password;
-        //  Reset the error log
+        //Reset the error log
         $this->errors = [];
-        //  connect
+        //Connect
         $result = $this->connect($this->host, $this->port, $this->tval);
         if ($result) {
             $login_result = $this->login($this->username, $this->password);
@@ -199,7 +226,7 @@ class POP3
                 return true;
             }
         }
-        // We need to disconnect regardless of whether the login succeeded
+        //We need to disconnect regardless of whether the login succeeded
         $this->disconnect();
 
         return false;
@@ -216,7 +243,7 @@ class POP3
      */
     public function connect($host, $port = false, $tval = 30)
     {
-        //  Are we already connected?
+        //Are we already connected?
         if ($this->connected) {
             return true;
         }
@@ -229,20 +256,22 @@ class POP3
             $port = static::DEFAULT_PORT;
         }
 
-        //  connect to the POP3 server
+        //Connect to the POP3 server
+        $errno = 0;
+        $errstr = '';
         $this->pop_conn = fsockopen(
-            $host, //  POP3 Host
-            $port, //  Port #
-            $errno, //  Error Number
-            $errstr, //  Error Message
+            $host, //POP3 Host
+            $port, //Port #
+            $errno, //Error Number
+            $errstr, //Error Message
             $tval
-        ); //  Timeout (seconds)
-        //  Restore the error handler
+        ); //Timeout (seconds)
+        //Restore the error handler
         restore_error_handler();
 
-        //  Did we connect?
+        //Did we connect?
         if (false === $this->pop_conn) {
-            //  It would appear not...
+            //It would appear not...
             $this->setError(
                 "Failed to connect to server $host on port $port. errno: $errno; errstr: $errstr"
             );
@@ -250,14 +279,14 @@ class POP3
             return false;
         }
 
-        //  Increase the stream time-out
+        //Increase the stream time-out
         stream_set_timeout($this->pop_conn, $tval, 0);
 
-        //  Get the POP3 server response
+        //Get the POP3 server response
         $pop3_response = $this->getResponse();
-        //  Check for the +OK
+        //Check for the +OK
         if ($this->checkResponse($pop3_response)) {
-            //  The connection is established and the POP3 server is talking
+            //The connection is established and the POP3 server is talking
             $this->connected = true;
 
             return true;
@@ -279,6 +308,7 @@ class POP3
     {
         if (!$this->connected) {
             $this->setError('Not connected to POP3 server');
+            return false;
         }
         if (empty($username)) {
             $username = $this->username;
@@ -287,11 +317,11 @@ class POP3
             $password = $this->password;
         }
 
-        // Send the Username
+        //Send the Username
         $this->sendString("USER $username" . static::LE);
         $pop3_response = $this->getResponse();
         if ($this->checkResponse($pop3_response)) {
-            // Send the Password
+            //Send the Password
             $this->sendString("PASS $password" . static::LE);
             $pop3_response = $this->getResponse();
             if ($this->checkResponse($pop3_response)) {
@@ -308,6 +338,15 @@ class POP3
     public function disconnect()
     {
         $this->sendString('QUIT');
+
+        // RFC 1939 shows POP3 server sending a +OK response to the QUIT command.
+        // Try to get it.  Ignore any failures here.
+        try {
+            $this->getResponse();
+        } catch (Exception $e) {
+            //Do nothing
+        }
+
         //The QUIT command may cause the daemon to exit, which will kill our connection
         //So ignore errors here
         try {
@@ -315,6 +354,10 @@ class POP3
         } catch (Exception $e) {
             //Do nothing
         }
+
+        // Clean up attributes.
+        $this->connected = false;
+        $this->pop_conn  = false;
     }
 
     /**
@@ -327,7 +370,7 @@ class POP3
     protected function getResponse($size = 128)
     {
         $response = fgets($this->pop_conn, $size);
-        if ($this->do_debug >= 1) {
+        if ($this->do_debug >= self::DEBUG_SERVER) {
             echo 'Server -> Client: ', $response;
         }
 
@@ -344,7 +387,7 @@ class POP3
     protected function sendString($string)
     {
         if ($this->pop_conn) {
-            if ($this->do_debug >= 2) { //Show client messages when debug >= 2
+            if ($this->do_debug >= self::DEBUG_CLIENT) { //Show client messages when debug >= 2
                 echo 'Client -> Server: ', $string;
             }
 
@@ -382,7 +425,7 @@ class POP3
     protected function setError($error)
     {
         $this->errors[] = $error;
-        if ($this->do_debug >= 1) {
+        if ($this->do_debug >= self::DEBUG_SERVER) {
             echo '<pre>';
             foreach ($this->errors as $e) {
                 print_r($e);

+ 161 - 76
core/External/PHPMailer/SMTP.php

@@ -1,4 +1,5 @@
 <?php
+
 /**
  * PHPMailer RFC821 SMTP email transport class.
  * PHP Version 5.5.
@@ -9,7 +10,7 @@
  * @author    Jim Jagielski (jimjag) <jimjag@gmail.com>
  * @author    Andy Prevost (codeworxtech) <codeworxtech@users.sourceforge.net>
  * @author    Brent R. Matzelle (original founder)
- * @copyright 2012 - 2019 Marcus Bointon
+ * @copyright 2012 - 2020 Marcus Bointon
  * @copyright 2010 - 2012 Jim Jagielski
  * @copyright 2004 - 2009 Andy Prevost
  * @license   http://www.gnu.org/copyleft/lesser.html GNU Lesser General Public License
@@ -34,7 +35,7 @@ class SMTP
      *
      * @var string
      */
-    const VERSION = '6.1.4';
+    const VERSION = '6.5.1';
 
     /**
      * SMTP line break constant.
@@ -185,6 +186,7 @@ class SMTP
         'Amazon_SES' => '/[\d]{3} Ok (.*)/',
         'SendGrid' => '/[\d]{3} Ok: queued as (.*)/',
         'CampaignMonitor' => '/[\d]{3} 2.0.0 OK:([a-zA-Z\d]{48})/',
+        'Haraka' => '/[\d]{3} Message Queued \((.*)\)/',
     ];
 
     /**
@@ -311,17 +313,11 @@ class SMTP
      */
     public function connect($host, $port = null, $timeout = 30, $options = [])
     {
-        static $streamok;
-        //This is enabled by default since 5.0.0 but some providers disable it
-        //Check this once and cache the result
-        if (null === $streamok) {
-            $streamok = function_exists('stream_socket_client');
-        }
-        // Clear errors to avoid confusion
+        //Clear errors to avoid confusion
         $this->setError('');
-        // Make sure we are __not__ connected
+        //Make sure we are __not__ connected
         if ($this->connected()) {
-            // Already connected, generate error
+            //Already connected, generate error
             $this->setError('Already connected to a server');
 
             return false;
@@ -329,18 +325,66 @@ class SMTP
         if (empty($port)) {
             $port = self::DEFAULT_PORT;
         }
-        // Connect to the SMTP server
+        //Connect to the SMTP server
         $this->edebug(
             "Connection: opening to $host:$port, timeout=$timeout, options=" .
             (count($options) > 0 ? var_export($options, true) : 'array()'),
             self::DEBUG_CONNECTION
         );
+
+        $this->smtp_conn = $this->getSMTPConnection($host, $port, $timeout, $options);
+
+        if ($this->smtp_conn === false) {
+            //Error info already set inside `getSMTPConnection()`
+            return false;
+        }
+
+        $this->edebug('Connection: opened', self::DEBUG_CONNECTION);
+
+        //Get any announcement
+        $this->last_reply = $this->get_lines();
+        $this->edebug('SERVER -> CLIENT: ' . $this->last_reply, self::DEBUG_SERVER);
+        $responseCode = (int)substr($this->last_reply, 0, 3);
+        if ($responseCode === 220) {
+            return true;
+        }
+        //Anything other than a 220 response means something went wrong
+        //RFC 5321 says the server will wait for us to send a QUIT in response to a 554 error
+        //https://tools.ietf.org/html/rfc5321#section-3.1
+        if ($responseCode === 554) {
+            $this->quit();
+        }
+        //This will handle 421 responses which may not wait for a QUIT (e.g. if the server is being shut down)
+        $this->edebug('Connection: closing due to error', self::DEBUG_CONNECTION);
+        $this->close();
+        return false;
+    }
+
+    /**
+     * Create connection to the SMTP server.
+     *
+     * @param string $host    SMTP server IP or host name
+     * @param int    $port    The port number to connect to
+     * @param int    $timeout How long to wait for the connection to open
+     * @param array  $options An array of options for stream_context_create()
+     *
+     * @return false|resource
+     */
+    protected function getSMTPConnection($host, $port = null, $timeout = 30, $options = [])
+    {
+        static $streamok;
+        //This is enabled by default since 5.0.0 but some providers disable it
+        //Check this once and cache the result
+        if (null === $streamok) {
+            $streamok = function_exists('stream_socket_client');
+        }
+
         $errno = 0;
         $errstr = '';
         if ($streamok) {
             $socket_context = stream_context_create($options);
             set_error_handler([$this, 'errorHandler']);
-            $this->smtp_conn = stream_socket_client(
+            $connection = stream_socket_client(
                 $host . ':' . $port,
                 $errno,
                 $errstr,
@@ -348,7 +392,6 @@ class SMTP
                 STREAM_CLIENT_CONNECT,
                 $socket_context
             );
-            restore_error_handler();
         } else {
             //Fall back to fsockopen which should work in more places, but is missing some features
             $this->edebug(
@@ -356,17 +399,18 @@ class SMTP
                 self::DEBUG_CONNECTION
             );
             set_error_handler([$this, 'errorHandler']);
-            $this->smtp_conn = fsockopen(
+            $connection = fsockopen(
                 $host,
                 $port,
                 $errno,
                 $errstr,
                 $timeout
             );
-            restore_error_handler();
         }
-        // Verify we connected properly
-        if (!is_resource($this->smtp_conn)) {
+        restore_error_handler();
+
+        //Verify we connected properly
+        if (!is_resource($connection)) {
             $this->setError(
                 'Failed to connect to server',
                 '',
@@ -381,22 +425,19 @@ class SMTP
 
             return false;
         }
-        $this->edebug('Connection: opened', self::DEBUG_CONNECTION);
-        // SMTP server can take longer to respond, give longer timeout for first read
-        // Windows does not have support for this timeout function
+
+        //SMTP server can take longer to respond, give longer timeout for first read
+        //Windows does not have support for this timeout function
         if (strpos(PHP_OS, 'WIN') !== 0) {
-            $max = (int) ini_get('max_execution_time');
-            // Don't bother if unlimited
-            if (0 !== $max && $timeout > $max) {
+            $max = (int)ini_get('max_execution_time');
+            //Don't bother if unlimited, or if set_time_limit is disabled
+            if (0 !== $max && $timeout > $max && strpos(ini_get('disable_functions'), 'set_time_limit') === false) {
                 @set_time_limit($timeout);
             }
-            stream_set_timeout($this->smtp_conn, $timeout, 0);
+            stream_set_timeout($connection, $timeout, 0);
         }
-        // Get any announcement
-        $announce = $this->get_lines();
-        $this->edebug('SERVER -> CLIENT: ' . $announce, self::DEBUG_SERVER);
 
-        return true;
+        return $connection;
     }
 
     /**
@@ -420,7 +461,7 @@ class SMTP
             $crypto_method |= STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT;
         }
 
-        // Begin encrypted connection
+        //Begin encrypted connection
         set_error_handler([$this, 'errorHandler']);
         $crypto_ok = stream_socket_enable_crypto(
             $this->smtp_conn,
@@ -458,11 +499,11 @@ class SMTP
         }
 
         if (array_key_exists('EHLO', $this->server_caps)) {
-            // SMTP extensions are available; try to find a proper authentication method
+            //SMTP extensions are available; try to find a proper authentication method
             if (!array_key_exists('AUTH', $this->server_caps)) {
                 $this->setError('Authentication is not allowed at this stage');
-                // 'at this stage' means that auth may be allowed after the stage changes
-                // e.g. after STARTTLS
+                //'at this stage' means that auth may be allowed after the stage changes
+                //e.g. after STARTTLS
 
                 return false;
             }
@@ -506,22 +547,25 @@ class SMTP
         }
         switch ($authtype) {
             case 'PLAIN':
-                // Start authentication
+                //Start authentication
                 if (!$this->sendCommand('AUTH', 'AUTH PLAIN', 334)) {
                     return false;
                 }
-                // Send encoded username and password
-                if (!$this->sendCommand(
-                    'User & Password',
-                    base64_encode("\0" . $username . "\0" . $password),
-                    235
-                )
+                //Send encoded username and password
+                if (
+                    //Format from https://tools.ietf.org/html/rfc4616#section-2
+                    //We skip the first field (it's forgery), so the string starts with a null byte
+                    !$this->sendCommand(
+                        'User & Password',
+                        base64_encode("\0" . $username . "\0" . $password),
+                        235
+                    )
                 ) {
                     return false;
                 }
                 break;
             case 'LOGIN':
-                // Start authentication
+                //Start authentication
                 if (!$this->sendCommand('AUTH', 'AUTH LOGIN', 334)) {
                     return false;
                 }
@@ -533,17 +577,17 @@ class SMTP
                 }
                 break;
             case 'CRAM-MD5':
-                // Start authentication
+                //Start authentication
                 if (!$this->sendCommand('AUTH CRAM-MD5', 'AUTH CRAM-MD5', 334)) {
                     return false;
                 }
-                // Get the challenge
+                //Get the challenge
                 $challenge = base64_decode(substr($this->last_reply, 4));
 
-                // Build the response
+                //Build the response
                 $response = $username . ' ' . $this->hmac($challenge, $password);
 
-                // send encoded credentials
+                //send encoded credentials
                 return $this->sendCommand('Username', base64_encode($response), 235);
             case 'XOAUTH2':
                 //The OAuth instance must be set up prior to requesting auth.
@@ -552,7 +596,7 @@ class SMTP
                 }
                 $oauth = $OAuth->getOauth64();
 
-                // Start authentication
+                //Start authentication
                 if (!$this->sendCommand('AUTH', 'AUTH XOAUTH2 ' . $oauth, 235)) {
                     return false;
                 }
@@ -582,15 +626,15 @@ class SMTP
             return hash_hmac('md5', $data, $key);
         }
 
-        // The following borrowed from
-        // http://php.net/manual/en/function.mhash.php#27225
+        //The following borrowed from
+        //http://php.net/manual/en/function.mhash.php#27225
 
-        // RFC 2104 HMAC implementation for php.
-        // Creates an md5 HMAC.
-        // Eliminates the need to install mhash to compute a HMAC
-        // by Lance Rushing
+        //RFC 2104 HMAC implementation for php.
+        //Creates an md5 HMAC.
+        //Eliminates the need to install mhash to compute a HMAC
+        //by Lance Rushing
 
-        $bytelen = 64; // byte length for md5
+        $bytelen = 64; //byte length for md5
         if (strlen($key) > $bytelen) {
             $key = pack('H*', md5($key));
         }
@@ -613,7 +657,7 @@ class SMTP
         if (is_resource($this->smtp_conn)) {
             $sock_status = stream_get_meta_data($this->smtp_conn);
             if ($sock_status['eof']) {
-                // The socket is valid but we are not connected
+                //The socket is valid but we are not connected
                 $this->edebug(
                     'SMTP NOTICE: EOF caught while checking if connected',
                     self::DEBUG_CLIENT
@@ -623,7 +667,7 @@ class SMTP
                 return false;
             }
 
-            return true; // everything looks good
+            return true; //everything looks good
         }
 
         return false;
@@ -641,7 +685,7 @@ class SMTP
         $this->server_caps = null;
         $this->helo_rply = null;
         if (is_resource($this->smtp_conn)) {
-            // close the connection and cleanup
+            //Close the connection and cleanup
             fclose($this->smtp_conn);
             $this->smtp_conn = null; //Makes for cleaner serialization
             $this->edebug('Connection: closed', self::DEBUG_CONNECTION);
@@ -651,7 +695,7 @@ class SMTP
     /**
      * Send an SMTP DATA command.
      * Issues a data command and sends the msg_data to the server,
-     * finializing the mail transaction. $msg_data is the message
+     * finalizing the mail transaction. $msg_data is the message
      * that is to be send with the headers. Each header needs to be
      * on a single line followed by a <CRLF> with the message headers
      * and the message body being separated by an additional <CRLF>.
@@ -676,7 +720,7 @@ class SMTP
          * NOTE: this does not count towards line-length limit.
          */
 
-        // Normalize line breaks before exploding
+        //Normalize line breaks before exploding
         $lines = explode("\n", str_replace(["\r\n", "\r"], "\n", $msg_data));
 
         /* To distinguish between a complete RFC822 message and a plain message body, we check if the first field
@@ -722,7 +766,8 @@ class SMTP
 
             //Send the lines to the server
             foreach ($lines_out as $line_out) {
-                //RFC2821 section 4.5.2
+                //Dot-stuffing as per RFC5321 section 4.5.2
+                //https://tools.ietf.org/html/rfc5321#section-4.5.2
                 if (!empty($line_out) && $line_out[0] === '.') {
                     $line_out = '.' . $line_out;
                 }
@@ -756,7 +801,16 @@ class SMTP
     public function hello($host = '')
     {
         //Try extended hello first (RFC 2821)
-        return $this->sendHello('EHLO', $host) or $this->sendHello('HELO', $host);
+        if ($this->sendHello('EHLO', $host)) {
+            return true;
+        }
+
+        //Some servers shut down the SMTP service here (RFC 5321)
+        if (substr($this->helo_rply, 0, 3) == '421') {
+            return false;
+        }
+
+        return $this->sendHello('HELO', $host);
     }
 
     /**
@@ -946,12 +1000,12 @@ class SMTP
         $this->client_send($commandstring . static::LE, $command);
 
         $this->last_reply = $this->get_lines();
-        // Fetch SMTP code and possible error code explanation
+        //Fetch SMTP code and possible error code explanation
         $matches = [];
         if (preg_match('/^([\d]{3})[ -](?:([\d]\\.[\d]\\.[\d]{1,2}) )?/', $this->last_reply, $matches)) {
             $code = (int) $matches[1];
             $code_ex = (count($matches) > 2 ? $matches[2] : null);
-            // Cut off error code from each response line
+            //Cut off error code from each response line
             $detail = preg_replace(
                 "/{$code}[ -]" .
                 ($code_ex ? str_replace('.', '\\.', $code_ex) . ' ' : '') . '/m',
@@ -959,7 +1013,7 @@ class SMTP
                 $this->last_reply
             );
         } else {
-            // Fall back to simple parsing if regex fails
+            //Fall back to simple parsing if regex fails
             $code = (int) substr($this->last_reply, 0, 3);
             $code_ex = null;
             $detail = substr($this->last_reply, 4);
@@ -1058,8 +1112,10 @@ class SMTP
     {
         //If SMTP transcripts are left enabled, or debug output is posted online
         //it can leak credentials, so hide credentials in all but lowest level
-        if (self::DEBUG_LOWLEVEL > $this->do_debug &&
-            in_array($command, ['User & Password', 'Username', 'Password'], true)) {
+        if (
+            self::DEBUG_LOWLEVEL > $this->do_debug &&
+            in_array($command, ['User & Password', 'Username', 'Password'], true)
+        ) {
             $this->edebug('CLIENT -> SERVER: [credentials hidden]', self::DEBUG_CLIENT);
         } else {
             $this->edebug('CLIENT -> SERVER: ' . $data, self::DEBUG_CLIENT);
@@ -1113,7 +1169,7 @@ class SMTP
         if (!$this->server_caps) {
             $this->setError('No HELO/EHLO was sent');
 
-            return;
+            return null;
         }
 
         if (!array_key_exists($name, $this->server_caps)) {
@@ -1125,7 +1181,7 @@ class SMTP
             }
             $this->setError('HELO handshake was used; No information about server extensions available');
 
-            return;
+            return null;
         }
 
         return $this->server_caps[$name];
@@ -1152,7 +1208,7 @@ class SMTP
      */
     protected function get_lines()
     {
-        // If the connection is bad, give up straight away
+        //If the connection is bad, give up straight away
         if (!is_resource($this->smtp_conn)) {
             return '';
         }
@@ -1166,33 +1222,61 @@ class SMTP
         $selW = null;
         while (is_resource($this->smtp_conn) && !feof($this->smtp_conn)) {
             //Must pass vars in here as params are by reference
-            if (!stream_select($selR, $selW, $selW, $this->Timelimit)) {
+            //solution for signals inspired by https://github.com/symfony/symfony/pull/6540
+            set_error_handler([$this, 'errorHandler']);
+            $n = stream_select($selR, $selW, $selW, $this->Timelimit);
+            restore_error_handler();
+
+            if ($n === false) {
+                $message = $this->getError()['detail'];
+
+                $this->edebug(
+                    'SMTP -> get_lines(): select failed (' . $message . ')',
+                    self::DEBUG_LOWLEVEL
+                );
+
+                //stream_select returns false when the `select` system call is interrupted
+                //by an incoming signal, try the select again
+                if (stripos($message, 'interrupted system call') !== false) {
+                    $this->edebug(
+                        'SMTP -> get_lines(): retrying stream_select',
+                        self::DEBUG_LOWLEVEL
+                    );
+                    $this->setError('');
+                    continue;
+                }
+
+                break;
+            }
+
+            if (!$n) {
                 $this->edebug(
-                    'SMTP -> get_lines(): timed-out (' . $this->Timeout . ' sec)',
+                    'SMTP -> get_lines(): select timed-out in (' . $this->Timelimit . ' sec)',
                     self::DEBUG_LOWLEVEL
                 );
                 break;
             }
+
             //Deliberate noise suppression - errors are handled afterwards
             $str = @fgets($this->smtp_conn, self::MAX_REPLY_LENGTH);
             $this->edebug('SMTP INBOUND: "' . trim($str) . '"', self::DEBUG_LOWLEVEL);
             $data .= $str;
-            // If response is only 3 chars (not valid, but RFC5321 S4.2 says it must be handled),
-            // or 4th character is a space or a line break char, we are done reading, break the loop.
-            // String array access is a significant micro-optimisation over strlen
+            //If response is only 3 chars (not valid, but RFC5321 S4.2 says it must be handled),
+            //or 4th character is a space or a line break char, we are done reading, break the loop.
+            //String array access is a significant micro-optimisation over strlen
             if (!isset($str[3]) || $str[3] === ' ' || $str[3] === "\r" || $str[3] === "\n") {
                 break;
             }
-            // Timed-out? Log and break
+            //Timed-out? Log and break
             $info = stream_get_meta_data($this->smtp_conn);
             if ($info['timed_out']) {
                 $this->edebug(
-                    'SMTP -> get_lines(): timed-out (' . $this->Timeout . ' sec)',
+                    'SMTP -> get_lines(): stream timed-out (' . $this->Timeout . ' sec)',
                     self::DEBUG_LOWLEVEL
                 );
                 break;
             }
-            // Now check if reads took too long
+            //Now check if reads took too long
             if ($endtime && time() > $endtime) {
                 $this->edebug(
                     'SMTP -> get_lines(): timelimit reached (' .
@@ -1344,6 +1428,7 @@ class SMTP
         } else {
             $this->last_smtp_transaction_id = false;
             foreach ($this->smtp_transaction_id_patterns as $smtp_transaction_id_pattern) {
+                $matches = [];
                 if (preg_match($smtp_transaction_id_pattern, $reply, $matches)) {
                     $this->last_smtp_transaction_id = trim($matches[1]);
                     break;

+ 117 - 47
core/External/ZipStream/File.php

@@ -26,16 +26,16 @@ namespace External\ZipStream {
     private $content = '';
     private $fileHandle = false;
     private $lastModificationTimestamp;
-    private $crc32 = null;
-    private $fileSize = 0;
-    private $compressedSize = 0;
+    protected $fileSize = 0;
+    protected $compressedSize = 0;
     private $offset = 0;
     private $bitField = 0;
-    private $useCompression = true;
+    protected $useCompression = true;
     private $deflateState = null;
 
     //check for duplications //currently not used
-    private $sha256;
+    protected $crc32 = null;
+    protected $sha256;
 
     public const BIT_NO_SIZE_IN_HEADER = 0b0000000000001000;
     public const BIT_UTF8_NAMES = 0b0000100000000000;
@@ -45,12 +45,17 @@ namespace External\ZipStream {
       $this->lastModificationTimestamp = time();
       $this->crc32 = hash('crc32b', '', true);
       $this->compressedSize = 0;
+      $this->fileSize = 0;
 
       $this->bitField = 0;
       $this->bitField |= self::BIT_NO_SIZE_IN_HEADER;
       $this->bitField |= self::BIT_UTF8_NAMES;
 
-      $this->deflateState = deflate_init(ZLIB_ENCODING_RAW, ['level' => 9]);
+      $this->deflateState = deflate_init(ZLIB_ENCODING_RAW);
+    }
+
+    public function disableCompression() {
+      $this->useCompression = false;
     }
 
     public function setContent($content) {
@@ -68,13 +73,6 @@ namespace External\ZipStream {
       $this->fileHandle = fopen($filename, 'rb');
     }
 
-    public function loadFromBuffer($buf) {
-      $this->crc32 = hash('crc32b', $buf, true);
-      $this->sha256 = hash('sha256', $buf);
-      $this->fileSize = strlen($buf);
-      $this->content = $buf;
-    }
-
     public function name() {
       return $this->name;
     }
@@ -101,14 +99,14 @@ namespace External\ZipStream {
         ($day);
     }
 
-    public function readLocalFileHeader() {
+    public function readLocalFileHeader(bool $zip64 = false) {
       if (!$this->useCompression) {
         $this->compressedSize = $this->fileSize;
       }
-
+      
       $header = "";
       $header .= "\x50\x4b\x03\x04";
-      $header .= "\x14\x00"; //version 2.0 and MS-DOS compatible
+      $header .= $zip64 ? "\x2d\x00" : "\x14\x00"; //version 2.0 and MS-DOS compatible
       $header .= pack("v", $this->bitField); //general purpose bit flag
       if ($this->useCompression) {
         $header .= "\x08\x00"; //compression Method - deflate
@@ -117,28 +115,59 @@ namespace External\ZipStream {
       }
       $header .= pack("v", $this->unixTimeToDosTime($this->lastModificationTimestamp)); //dos time
       $header .= pack("v", $this->unixTimeToDosDate($this->lastModificationTimestamp)); //dos date
-      if ($this->bitField & self::BIT_NO_SIZE_IN_HEADER) {
-        $header .= pack("V", 0); //crc32
-        $header .= pack("V", 0); //compressed Size
-        $header .= pack("V", 0); //uncompressed Size
+
+      if ($zip64) {
+        if ($this->bitField & self::BIT_NO_SIZE_IN_HEADER) {
+          $header .= pack("V", 0); //crc32
+        } else {
+          $header .= strrev($this->crc32);
+        }
+        $header .= "\xFF\xFF\xFF\xFF"; //compressed Size
+        $header .= "\xFF\xFF\xFF\xFF"; //uncompressed Size
       } else {
-        $header .= strrev($this->crc32);
-        $header .= pack("V", $this->compressedSize); //compressed Size
-        $header .= pack("V", $this->fileSize); //uncompressed Size
+        if ($this->bitField & self::BIT_NO_SIZE_IN_HEADER) {
+          $header .= pack("V", 0); //crc32
+          $header .= pack("V", 0); //compressed Size
+          $header .= pack("V", 0); //uncompressed Size
+        } else {
+          $header .= strrev($this->crc32);
+          $header .= pack("V", $this->compressedSize); //compressed Size
+          $header .= pack("V", $this->fileSize); //uncompressed Size
+        }
       }
+
       $header .= pack("v", strlen($this->name)); //filename
-      $header .= "\x00\x00"; //extra field length
-      $header .= $this->name;
+      if ($zip64) {
+        $header .= pack("v", 16+4); //extra field length (signatures + data)
+        $header .= $this->name;
+        $header .= pack("v", 0x0001); # Zip64 extended information extra field
+        $header .= pack("v", 16); // 2 * 8 byte
+        if ($this->bitField & self::BIT_NO_SIZE_IN_HEADER) {
+          $header .= pack("P", 0);
+          $header .= pack("P", 0);
+        } else {
+          $header .= pack("P", $this->compressedSize);
+          $header .= pack("P", $this->fileSize);
+        }
+      } else {
+        $header .= "\x00\x00"; //extra field length
+        $header .= $this->name;
+      }
 
       return $header;
     }
 
-    public function readDataDescriptor() {
+    public function readDataDescriptor(bool $zip64 = false) {
+
+      if (!$this->useCompression) {
+        $this->compressedSize = $this->fileSize;
+      }
+
       $data = "";
       $data .= "\x50\x4b\x07\x08";
       $data .= strrev($this->crc32);
-      $data .= pack("V", $this->compressedSize); //compressed Size
-      $data .= pack("V", $this->fileSize); //uncompressed Size
+      $data .= $zip64 ? pack("P", $this->compressedSize) : pack("V", $this->compressedSize); //compressed Size
+      $data .= $zip64 ? pack("P", $this->fileSize) : pack("V", $this->fileSize); //uncompressed Size
       return $data;
     }
 
@@ -156,21 +185,28 @@ namespace External\ZipStream {
       return $ret;
     }
 
+    protected function compress($block) {
+
+      $ret = null;
+      if ($this->deflateState !== null) {
+        if (!empty($block)) {
+          $ret = deflate_add($this->deflateState, $block, ZLIB_NO_FLUSH);
+        } else {
+          $ret = deflate_add($this->deflateState, '', ZLIB_FINISH);
+          $this->deflateState = null;
+        }
+
+        $this->compressedSize += strlen($ret);
+      }
+
+      return $ret;
+    }
+
     public function readFileData() {
       $ret = null;
       if ($this->useCompression) {
         $block = $this->readFileDataImp();
-        if ($this->deflateState !== null) {
-          if ($block !== null) {
-            $ret = deflate_add($this->deflateState, $block, ZLIB_NO_FLUSH);
-          } else {
-            $ret = deflate_add($this->deflateState, '', ZLIB_FINISH);
-            $this->deflateState = null;
-          }
-        }
-        if ($ret !== null) {
-          $this->compressedSize += strlen($ret);
-        }
+        $ret = $this->compress($block);
       } else {
         $ret = $this->readFileDataImp();
       }
@@ -181,27 +217,61 @@ namespace External\ZipStream {
       $this->offset = $offset;
     }
 
-    public function readCentralDirectoryHeader() {
+    public function readCentralDirectoryHeader(bool $zip64 = false) {
+
+      $maxInt32 = 0xFFFFFFFF;
+      $extraFields = "";
+
+      // Compressed Size
+      if ($zip64 && $this->compressedSize >= $maxInt32) {
+        $compressedSize = "\xFF\xFF\xFF\xFF";
+        $extraFields .= pack("P", $this->compressedSize);
+      } else {
+        $compressedSize = pack("V", $this->compressedSize);
+      }
+
+      // Uncompressed Size
+      if ($zip64 && $this->fileSize >= $maxInt32) {
+        $fileSize = "\xFF\xFF\xFF\xFF";
+        $extraFields .= pack("P", $this->fileSize);
+      } else {
+        $fileSize = pack("V", $this->fileSize);
+      }
+
+      // Offset
+      if ($zip64 && $this->offset >= $maxInt32) {
+        $offset = "\xFF\xFF\xFF\xFF";
+        $extraFields .= pack("P", $this->offset);
+      } else {
+        $offset = pack("V", $this->offset);
+      }
+
       $header = "";
       $header .= "\x50\x4b\x01\x02";
-      $header .= "\x14\x00"; //version 2.0 and MS-DOS compatible
-      $header .= "\x14\x00"; //version 2.0 and MS-DOS compatible
+      $header .= $zip64 ? "\x2d\x00" : "\x14\x00"; //version 2.0 and MS-DOS compatible
+      $header .= $zip64 ? "\x2d\x00" : "\x14\x00"; //version 2.0 and MS-DOS compatible
       $header .= pack("v", $this->bitField); //general purpose bit flag
-      $header .= "\x00\x00"; //compression Method - no
+      $header .= $this->useCompression ? "\x08\x00" : "\x00\x00"; //compression Method - no
       $header .= pack("v", $this->unixTimeToDosTime($this->lastModificationTimestamp)); //dos time
       $header .= pack("v", $this->unixTimeToDosDate($this->lastModificationTimestamp)); //dos date
       $header .= strrev($this->crc32);
-      $header .= pack("V", $this->compressedSize); //compressed Size
-      $header .= pack("V", $this->fileSize); //uncompressed Size
+      $header .= $compressedSize; //compressed Size
+      $header .= $fileSize; //uncompressed Size
       $header .= pack("v", strlen($this->name)); //filename
-      $header .= "\x00\x00"; //extra field length
+      $header .= (strlen($extraFields) > 0) ? pack('v', strlen($extraFields) + 4) : "\x00\x00"; //extra field length
       $header .= "\x00\x00"; //comment length
       $header .= "\x00\x00"; //disk num start
       $header .= "\x00\x00"; //int file attr
       $header .= "\x00\x00\x00\x00"; //ext file attr
-      $header .= pack("V", $this->offset); //relative offset
+      $header .= $offset; //relative offset
       $header .= $this->name;
 
+      if (strlen($extraFields) > 0) {
+        $header .= pack("v", 0x0001); # Zip64 extended information extra field
+        $header .= pack("v", strlen($extraFields));
+        $header .= $extraFields;
+      }
+
       return $header;
     }
 

+ 46 - 0
core/External/ZipStream/FileStream.class.php

@@ -0,0 +1,46 @@
+<?php
+
+namespace External\ZipStream {
+
+  use HashContext;
+  use Objects\AesStream;
+
+  class FileStream extends File {
+
+    private AesStream $stream;
+    private HashContext $crc32ctx;
+    private HashContext $sha256ctx;
+
+    public function __construct(AesStream $stream, string $name) {
+      parent::__construct($name);
+      $this->stream = $stream;
+      $this->crc32ctx = hash_init('crc32b');
+      $this->sha256ctx = hash_init('sha256');
+    }
+
+    public function getStream(): AesStream {
+      return $this->stream;
+    }
+
+    public function finalize() {
+      $this->crc32 = hash_final($this->crc32ctx, true);
+      $this->sha256 = hash_final($this->sha256ctx);
+      return $this->compress(null);
+    }
+
+    public function processChunk($chunk) {
+
+      hash_update($this->crc32ctx, $chunk);
+      hash_update($this->sha256ctx, $chunk);
+      $this->fileSize += strlen($chunk);
+
+      if ($this->useCompression) {
+        $chunk = $this->compress($chunk);
+      }
+
+      return $chunk;
+    }
+
+  }
+
+}

+ 48 - 12
core/External/ZipStream/ZipStream.php

@@ -23,10 +23,12 @@
 namespace External\ZipStream {
   class ZipStream {
     private $writer = null;
-    private $files = [];
+    private array $files = [];
+    private bool $zip64;
 
-    public function __construct($writer) {
+    public function __construct($writer, $zip64 = false) {
       $this->writer = $writer;
+      $this->zip64 = $zip64;
     }
 
     public function saveFile($file) {
@@ -40,32 +42,66 @@ namespace External\ZipStream {
         }
       }
       $file->setOffset($this->writer->offset());
-      $this->writer->write($file->readLocalFileHeader());
-      while (($buffer = $file->readFileData()) !== null) {
-        $this->writer->write($buffer);
+      $this->writer->write($file->readLocalFileHeader($this->zip64));
+
+      if ($file instanceof FileStream) {
+        $file->getStream()->setOutput(function ($chunk) use ($file) {
+          $this->writer->write($file->processChunk($chunk));
+        });
+        $file->getStream()->start();
+        $this->writer->write($file->finalize());
+      } else {
+        while (($buffer = $file->readFileData()) !== null) {
+          $this->writer->write($buffer);
+        }
       }
-      $this->writer->write($file->readDataDescriptor());
+
+      $this->writer->write($file->readDataDescriptor($this->zip64));
       $this->files[] = $file;
       $file->closeHandle();
       return true;
     }
 
+    // Write end of central directory record
     public function close() {
       $size = 0;
       $offset = $this->writer->offset();
       foreach ($this->files as $file) {
-        $size += $this->writer->write($file->readCentralDirectoryHeader());
+        $size += $this->writer->write($file->readCentralDirectoryHeader($this->zip64));
       }
 
       $data = "";
+      if ($this->zip64) {
+        // Size = SizeOfFixedFields + SizeOfVariableData - 12.
+        $centralDirectorySize = 2*2 + 2*4 + 4*8;
+
+        $data .= "\x50\x4b\x06\x06";
+        $data .= pack("P", $centralDirectorySize);
+        $data .= "\x2d\x00"; // version 2.0 and MS-DOS compatible
+        $data .= "\x2d\x00"; // version 2.0 and MS-DOS compatible
+        $data .= "\x00\x00\x00\x00"; //number of disks
+        $data .= "\x00\x00\x00\x00"; //number of the disk with the start of the central directory
+        $data .= pack("P", count($this->files)); //total number of entries in the central directory on this disk
+        $data .= pack("P", count($this->files)); //total number of entries in the central directory
+        $data .= pack("P", $size); // size of the central directory
+        $data .= pack("P", $offset); //offset of start of central directory with respect to the starting disk number
+
+        // end of central directory locator
+        $data .= "\x50\x4b\x06\x07";
+        $data .= "\x00\x00\x00\x00";
+        $data .= pack("P", $this->writer->offset());
+        $data .= pack('V', 1); //number of disks
+      }
+
       $data .= "\x50\x4b\x05\x06";
       $data .= "\x00\x00"; //number of disks
       $data .= "\x00\x00"; //number of the disk with the start of the central directory
-      $data .= pack("v", count($this->files)); //total number of entries in the central directory on this disk
-      $data .= pack("v", count($this->files)); //total number of entries in the central directory
-      $data .= pack("V", $size); //size of the central directory
-      $data .= pack("V", $offset); //offset of start of central directory with respect to the starting disk number
-      $data .= "\x0\x0"; //comment length
+      $data .= $this->zip64 ? "\xFF\xFF" : pack("v", count($this->files)); //total number of entries in the central directory on this disk
+      $data .= $this->zip64 ? "\xFF\xFF" : pack("v", count($this->files)); //total number of entries in the central directory
+      $data .= $this->zip64 ? "\xFF\xFF\xFF\xFF" : pack("V", $size); // size of the central directory
+      $data .= $this->zip64 ? "\xFF\xFF\xFF\xFF" : pack("V", $offset); //offset of start of central directory with respect to the starting disk number
+      $data .= "\x00\x00"; //comment length
+
       $this->writer->write($data);
     }
   }

+ 8 - 0
core/External/composer.json

@@ -0,0 +1,8 @@
+{
+    "require": {
+        "twig/twig": "^3.0"
+    },
+    "require-dev": {
+        "phpunit/phpunit": "^9.5"
+    }
+}

+ 2269 - 0
core/External/composer.lock

@@ -0,0 +1,2269 @@
+{
+    "_readme": [
+        "This file locks the dependencies of your project to a known state",
+        "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
+        "This file is @generated automatically"
+    ],
+    "content-hash": "4b2f957dbfdda946d9f57c693ac1809a",
+    "packages": [
+        {
+            "name": "symfony/polyfill-ctype",
+            "version": "v1.23.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/polyfill-ctype.git",
+                "reference": "46cd95797e9df938fdd2b03693b5fca5e64b01ce"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/46cd95797e9df938fdd2b03693b5fca5e64b01ce",
+                "reference": "46cd95797e9df938fdd2b03693b5fca5e64b01ce",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.1"
+            },
+            "suggest": {
+                "ext-ctype": "For best performance"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "1.23-dev"
+                },
+                "thanks": {
+                    "name": "symfony/polyfill",
+                    "url": "https://github.com/symfony/polyfill"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Polyfill\\Ctype\\": ""
+                },
+                "files": [
+                    "bootstrap.php"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Gert de Pagter",
+                    "email": "BackEndTea@gmail.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony polyfill for ctype functions",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "ctype",
+                "polyfill",
+                "portable"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/polyfill-ctype/tree/v1.23.0"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2021-02-19T12:13:01+00:00"
+        },
+        {
+            "name": "symfony/polyfill-mbstring",
+            "version": "v1.23.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/polyfill-mbstring.git",
+                "reference": "9174a3d80210dca8daa7f31fec659150bbeabfc6"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/9174a3d80210dca8daa7f31fec659150bbeabfc6",
+                "reference": "9174a3d80210dca8daa7f31fec659150bbeabfc6",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.1"
+            },
+            "suggest": {
+                "ext-mbstring": "For best performance"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "1.23-dev"
+                },
+                "thanks": {
+                    "name": "symfony/polyfill",
+                    "url": "https://github.com/symfony/polyfill"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Polyfill\\Mbstring\\": ""
+                },
+                "files": [
+                    "bootstrap.php"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony polyfill for the Mbstring extension",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "mbstring",
+                "polyfill",
+                "portable",
+                "shim"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.23.1"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2021-05-27T12:26:48+00:00"
+        },
+        {
+            "name": "twig/twig",
+            "version": "v3.3.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/twigphp/Twig.git",
+                "reference": "65cb6f0b956485e1664f13d023c55298a4bb59ca"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/twigphp/Twig/zipball/65cb6f0b956485e1664f13d023c55298a4bb59ca",
+                "reference": "65cb6f0b956485e1664f13d023c55298a4bb59ca",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2.5",
+                "symfony/polyfill-ctype": "^1.8",
+                "symfony/polyfill-mbstring": "^1.3"
+            },
+            "require-dev": {
+                "psr/container": "^1.0",
+                "symfony/phpunit-bridge": "^4.4.9|^5.0.9|^6.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "3.3-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Twig\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Fabien Potencier",
+                    "email": "fabien@symfony.com",
+                    "homepage": "http://fabien.potencier.org",
+                    "role": "Lead Developer"
+                },
+                {
+                    "name": "Twig Team",
+                    "role": "Contributors"
+                },
+                {
+                    "name": "Armin Ronacher",
+                    "email": "armin.ronacher@active-4.com",
+                    "role": "Project Founder"
+                }
+            ],
+            "description": "Twig, the flexible, fast, and secure template language for PHP",
+            "homepage": "https://twig.symfony.com",
+            "keywords": [
+                "templating"
+            ],
+            "support": {
+                "issues": "https://github.com/twigphp/Twig/issues",
+                "source": "https://github.com/twigphp/Twig/tree/v3.3.4"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/twig/twig",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2021-11-25T13:46:55+00:00"
+        }
+    ],
+    "packages-dev": [
+        {
+            "name": "doctrine/instantiator",
+            "version": "1.4.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/doctrine/instantiator.git",
+                "reference": "d56bf6102915de5702778fe20f2de3b2fe570b5b"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/doctrine/instantiator/zipball/d56bf6102915de5702778fe20f2de3b2fe570b5b",
+                "reference": "d56bf6102915de5702778fe20f2de3b2fe570b5b",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.1 || ^8.0"
+            },
+            "require-dev": {
+                "doctrine/coding-standard": "^8.0",
+                "ext-pdo": "*",
+                "ext-phar": "*",
+                "phpbench/phpbench": "^0.13 || 1.0.0-alpha2",
+                "phpstan/phpstan": "^0.12",
+                "phpstan/phpstan-phpunit": "^0.12",
+                "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Marco Pivetta",
+                    "email": "ocramius@gmail.com",
+                    "homepage": "https://ocramius.github.io/"
+                }
+            ],
+            "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors",
+            "homepage": "https://www.doctrine-project.org/projects/instantiator.html",
+            "keywords": [
+                "constructor",
+                "instantiate"
+            ],
+            "support": {
+                "issues": "https://github.com/doctrine/instantiator/issues",
+                "source": "https://github.com/doctrine/instantiator/tree/1.4.0"
+            },
+            "funding": [
+                {
+                    "url": "https://www.doctrine-project.org/sponsorship.html",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://www.patreon.com/phpdoctrine",
+                    "type": "patreon"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2020-11-10T18:47:58+00:00"
+        },
+        {
+            "name": "myclabs/deep-copy",
+            "version": "1.10.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/myclabs/DeepCopy.git",
+                "reference": "776f831124e9c62e1a2c601ecc52e776d8bb7220"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/776f831124e9c62e1a2c601ecc52e776d8bb7220",
+                "reference": "776f831124e9c62e1a2c601ecc52e776d8bb7220",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.1 || ^8.0"
+            },
+            "replace": {
+                "myclabs/deep-copy": "self.version"
+            },
+            "require-dev": {
+                "doctrine/collections": "^1.0",
+                "doctrine/common": "^2.6",
+                "phpunit/phpunit": "^7.1"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "DeepCopy\\": "src/DeepCopy/"
+                },
+                "files": [
+                    "src/DeepCopy/deep_copy.php"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "description": "Create deep copies (clones) of your objects",
+            "keywords": [
+                "clone",
+                "copy",
+                "duplicate",
+                "object",
+                "object graph"
+            ],
+            "support": {
+                "issues": "https://github.com/myclabs/DeepCopy/issues",
+                "source": "https://github.com/myclabs/DeepCopy/tree/1.10.2"
+            },
+            "funding": [
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2020-11-13T09:40:50+00:00"
+        },
+        {
+            "name": "nikic/php-parser",
+            "version": "v4.13.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/nikic/PHP-Parser.git",
+                "reference": "210577fe3cf7badcc5814d99455df46564f3c077"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/210577fe3cf7badcc5814d99455df46564f3c077",
+                "reference": "210577fe3cf7badcc5814d99455df46564f3c077",
+                "shasum": ""
+            },
+            "require": {
+                "ext-tokenizer": "*",
+                "php": ">=7.0"
+            },
+            "require-dev": {
+                "ircmaxell/php-yacc": "^0.0.7",
+                "phpunit/phpunit": "^6.5 || ^7.0 || ^8.0 || ^9.0"
+            },
+            "bin": [
+                "bin/php-parse"
+            ],
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "4.9-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "PhpParser\\": "lib/PhpParser"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Nikita Popov"
+                }
+            ],
+            "description": "A PHP parser written in PHP",
+            "keywords": [
+                "parser",
+                "php"
+            ],
+            "support": {
+                "issues": "https://github.com/nikic/PHP-Parser/issues",
+                "source": "https://github.com/nikic/PHP-Parser/tree/v4.13.2"
+            },
+            "time": "2021-11-30T19:35:32+00:00"
+        },
+        {
+            "name": "phar-io/manifest",
+            "version": "2.0.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/phar-io/manifest.git",
+                "reference": "97803eca37d319dfa7826cc2437fc020857acb53"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/phar-io/manifest/zipball/97803eca37d319dfa7826cc2437fc020857acb53",
+                "reference": "97803eca37d319dfa7826cc2437fc020857acb53",
+                "shasum": ""
+            },
+            "require": {
+                "ext-dom": "*",
+                "ext-phar": "*",
+                "ext-xmlwriter": "*",
+                "phar-io/version": "^3.0.1",
+                "php": "^7.2 || ^8.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.0.x-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Arne Blankerts",
+                    "email": "arne@blankerts.de",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Sebastian Heuer",
+                    "email": "sebastian@phpeople.de",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "Developer"
+                }
+            ],
+            "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)",
+            "support": {
+                "issues": "https://github.com/phar-io/manifest/issues",
+                "source": "https://github.com/phar-io/manifest/tree/2.0.3"
+            },
+            "time": "2021-07-20T11:28:43+00:00"
+        },
+        {
+            "name": "phar-io/version",
+            "version": "3.1.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/phar-io/version.git",
+                "reference": "bae7c545bef187884426f042434e561ab1ddb182"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/phar-io/version/zipball/bae7c545bef187884426f042434e561ab1ddb182",
+                "reference": "bae7c545bef187884426f042434e561ab1ddb182",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.2 || ^8.0"
+            },
+            "type": "library",
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Arne Blankerts",
+                    "email": "arne@blankerts.de",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Sebastian Heuer",
+                    "email": "sebastian@phpeople.de",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "Developer"
+                }
+            ],
+            "description": "Library for handling version information and constraints",
+            "support": {
+                "issues": "https://github.com/phar-io/version/issues",
+                "source": "https://github.com/phar-io/version/tree/3.1.0"
+            },
+            "time": "2021-02-23T14:00:09+00:00"
+        },
+        {
+            "name": "phpdocumentor/reflection-common",
+            "version": "2.2.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/phpDocumentor/ReflectionCommon.git",
+                "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b",
+                "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.2 || ^8.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-2.x": "2.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "phpDocumentor\\Reflection\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Jaap van Otterdijk",
+                    "email": "opensource@ijaap.nl"
+                }
+            ],
+            "description": "Common reflection classes used by phpdocumentor to reflect the code structure",
+            "homepage": "http://www.phpdoc.org",
+            "keywords": [
+                "FQSEN",
+                "phpDocumentor",
+                "phpdoc",
+                "reflection",
+                "static analysis"
+            ],
+            "support": {
+                "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues",
+                "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x"
+            },
+            "time": "2020-06-27T09:03:43+00:00"
+        },
+        {
+            "name": "phpdocumentor/reflection-docblock",
+            "version": "5.3.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git",
+                "reference": "622548b623e81ca6d78b721c5e029f4ce664f170"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/622548b623e81ca6d78b721c5e029f4ce664f170",
+                "reference": "622548b623e81ca6d78b721c5e029f4ce664f170",
+                "shasum": ""
+            },
+            "require": {
+                "ext-filter": "*",
+                "php": "^7.2 || ^8.0",
+                "phpdocumentor/reflection-common": "^2.2",
+                "phpdocumentor/type-resolver": "^1.3",
+                "webmozart/assert": "^1.9.1"
+            },
+            "require-dev": {
+                "mockery/mockery": "~1.3.2",
+                "psalm/phar": "^4.8"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "5.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "phpDocumentor\\Reflection\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Mike van Riel",
+                    "email": "me@mikevanriel.com"
+                },
+                {
+                    "name": "Jaap van Otterdijk",
+                    "email": "account@ijaap.nl"
+                }
+            ],
+            "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.",
+            "support": {
+                "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues",
+                "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.3.0"
+            },
+            "time": "2021-10-19T17:43:47+00:00"
+        },
+        {
+            "name": "phpdocumentor/type-resolver",
+            "version": "1.5.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/phpDocumentor/TypeResolver.git",
+                "reference": "a12f7e301eb7258bb68acd89d4aefa05c2906cae"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/a12f7e301eb7258bb68acd89d4aefa05c2906cae",
+                "reference": "a12f7e301eb7258bb68acd89d4aefa05c2906cae",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.2 || ^8.0",
+                "phpdocumentor/reflection-common": "^2.0"
+            },
+            "require-dev": {
+                "ext-tokenizer": "*",
+                "psalm/phar": "^4.8"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-1.x": "1.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "phpDocumentor\\Reflection\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Mike van Riel",
+                    "email": "me@mikevanriel.com"
+                }
+            ],
+            "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names",
+            "support": {
+                "issues": "https://github.com/phpDocumentor/TypeResolver/issues",
+                "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.5.1"
+            },
+            "time": "2021-10-02T14:08:47+00:00"
+        },
+        {
+            "name": "phpspec/prophecy",
+            "version": "v1.15.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/phpspec/prophecy.git",
+                "reference": "bbcd7380b0ebf3961ee21409db7b38bc31d69a13"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/phpspec/prophecy/zipball/bbcd7380b0ebf3961ee21409db7b38bc31d69a13",
+                "reference": "bbcd7380b0ebf3961ee21409db7b38bc31d69a13",
+                "shasum": ""
+            },
+            "require": {
+                "doctrine/instantiator": "^1.2",
+                "php": "^7.2 || ~8.0, <8.2",
+                "phpdocumentor/reflection-docblock": "^5.2",
+                "sebastian/comparator": "^3.0 || ^4.0",
+                "sebastian/recursion-context": "^3.0 || ^4.0"
+            },
+            "require-dev": {
+                "phpspec/phpspec": "^6.0 || ^7.0",
+                "phpunit/phpunit": "^8.0 || ^9.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Prophecy\\": "src/Prophecy"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Konstantin Kudryashov",
+                    "email": "ever.zet@gmail.com",
+                    "homepage": "http://everzet.com"
+                },
+                {
+                    "name": "Marcello Duarte",
+                    "email": "marcello.duarte@gmail.com"
+                }
+            ],
+            "description": "Highly opinionated mocking framework for PHP 5.3+",
+            "homepage": "https://github.com/phpspec/prophecy",
+            "keywords": [
+                "Double",
+                "Dummy",
+                "fake",
+                "mock",
+                "spy",
+                "stub"
+            ],
+            "support": {
+                "issues": "https://github.com/phpspec/prophecy/issues",
+                "source": "https://github.com/phpspec/prophecy/tree/v1.15.0"
+            },
+            "time": "2021-12-08T12:19:24+00:00"
+        },
+        {
+            "name": "phpunit/php-code-coverage",
+            "version": "9.2.10",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/php-code-coverage.git",
+                "reference": "d5850aaf931743067f4bfc1ae4cbd06468400687"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/d5850aaf931743067f4bfc1ae4cbd06468400687",
+                "reference": "d5850aaf931743067f4bfc1ae4cbd06468400687",
+                "shasum": ""
+            },
+            "require": {
+                "ext-dom": "*",
+                "ext-libxml": "*",
+                "ext-xmlwriter": "*",
+                "nikic/php-parser": "^4.13.0",
+                "php": ">=7.3",
+                "phpunit/php-file-iterator": "^3.0.3",
+                "phpunit/php-text-template": "^2.0.2",
+                "sebastian/code-unit-reverse-lookup": "^2.0.2",
+                "sebastian/complexity": "^2.0",
+                "sebastian/environment": "^5.1.2",
+                "sebastian/lines-of-code": "^1.0.3",
+                "sebastian/version": "^3.0.1",
+                "theseer/tokenizer": "^1.2.0"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "suggest": {
+                "ext-pcov": "*",
+                "ext-xdebug": "*"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "9.2-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.",
+            "homepage": "https://github.com/sebastianbergmann/php-code-coverage",
+            "keywords": [
+                "coverage",
+                "testing",
+                "xunit"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
+                "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.10"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2021-12-05T09:12:13+00:00"
+        },
+        {
+            "name": "phpunit/php-file-iterator",
+            "version": "3.0.6",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/php-file-iterator.git",
+                "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf",
+                "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "3.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "FilterIterator implementation that filters files based on a list of suffixes.",
+            "homepage": "https://github.com/sebastianbergmann/php-file-iterator/",
+            "keywords": [
+                "filesystem",
+                "iterator"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues",
+                "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/3.0.6"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2021-12-02T12:48:52+00:00"
+        },
+        {
+            "name": "phpunit/php-invoker",
+            "version": "3.1.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/php-invoker.git",
+                "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/5a10147d0aaf65b58940a0b72f71c9ac0423cc67",
+                "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "ext-pcntl": "*",
+                "phpunit/phpunit": "^9.3"
+            },
+            "suggest": {
+                "ext-pcntl": "*"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "3.1-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Invoke callables with a timeout",
+            "homepage": "https://github.com/sebastianbergmann/php-invoker/",
+            "keywords": [
+                "process"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/php-invoker/issues",
+                "source": "https://github.com/sebastianbergmann/php-invoker/tree/3.1.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-09-28T05:58:55+00:00"
+        },
+        {
+            "name": "phpunit/php-text-template",
+            "version": "2.0.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/php-text-template.git",
+                "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28",
+                "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Simple template engine.",
+            "homepage": "https://github.com/sebastianbergmann/php-text-template/",
+            "keywords": [
+                "template"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/php-text-template/issues",
+                "source": "https://github.com/sebastianbergmann/php-text-template/tree/2.0.4"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-10-26T05:33:50+00:00"
+        },
+        {
+            "name": "phpunit/php-timer",
+            "version": "5.0.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/php-timer.git",
+                "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2",
+                "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "5.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Utility class for timing",
+            "homepage": "https://github.com/sebastianbergmann/php-timer/",
+            "keywords": [
+                "timer"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/php-timer/issues",
+                "source": "https://github.com/sebastianbergmann/php-timer/tree/5.0.3"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-10-26T13:16:10+00:00"
+        },
+        {
+            "name": "phpunit/phpunit",
+            "version": "9.5.10",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/phpunit.git",
+                "reference": "c814a05837f2edb0d1471d6e3f4ab3501ca3899a"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/c814a05837f2edb0d1471d6e3f4ab3501ca3899a",
+                "reference": "c814a05837f2edb0d1471d6e3f4ab3501ca3899a",
+                "shasum": ""
+            },
+            "require": {
+                "doctrine/instantiator": "^1.3.1",
+                "ext-dom": "*",
+                "ext-json": "*",
+                "ext-libxml": "*",
+                "ext-mbstring": "*",
+                "ext-xml": "*",
+                "ext-xmlwriter": "*",
+                "myclabs/deep-copy": "^1.10.1",
+                "phar-io/manifest": "^2.0.3",
+                "phar-io/version": "^3.0.2",
+                "php": ">=7.3",
+                "phpspec/prophecy": "^1.12.1",
+                "phpunit/php-code-coverage": "^9.2.7",
+                "phpunit/php-file-iterator": "^3.0.5",
+                "phpunit/php-invoker": "^3.1.1",
+                "phpunit/php-text-template": "^2.0.3",
+                "phpunit/php-timer": "^5.0.2",
+                "sebastian/cli-parser": "^1.0.1",
+                "sebastian/code-unit": "^1.0.6",
+                "sebastian/comparator": "^4.0.5",
+                "sebastian/diff": "^4.0.3",
+                "sebastian/environment": "^5.1.3",
+                "sebastian/exporter": "^4.0.3",
+                "sebastian/global-state": "^5.0.1",
+                "sebastian/object-enumerator": "^4.0.3",
+                "sebastian/resource-operations": "^3.0.3",
+                "sebastian/type": "^2.3.4",
+                "sebastian/version": "^3.0.2"
+            },
+            "require-dev": {
+                "ext-pdo": "*",
+                "phpspec/prophecy-phpunit": "^2.0.1"
+            },
+            "suggest": {
+                "ext-soap": "*",
+                "ext-xdebug": "*"
+            },
+            "bin": [
+                "phpunit"
+            ],
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "9.5-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ],
+                "files": [
+                    "src/Framework/Assert/Functions.php"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "The PHP Unit Testing framework.",
+            "homepage": "https://phpunit.de/",
+            "keywords": [
+                "phpunit",
+                "testing",
+                "xunit"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/phpunit/issues",
+                "source": "https://github.com/sebastianbergmann/phpunit/tree/9.5.10"
+            },
+            "funding": [
+                {
+                    "url": "https://phpunit.de/donate.html",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2021-09-25T07:38:51+00:00"
+        },
+        {
+            "name": "sebastian/cli-parser",
+            "version": "1.0.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/cli-parser.git",
+                "reference": "442e7c7e687e42adc03470c7b668bc4b2402c0b2"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/442e7c7e687e42adc03470c7b668bc4b2402c0b2",
+                "reference": "442e7c7e687e42adc03470c7b668bc4b2402c0b2",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Library for parsing CLI options",
+            "homepage": "https://github.com/sebastianbergmann/cli-parser",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/cli-parser/issues",
+                "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-09-28T06:08:49+00:00"
+        },
+        {
+            "name": "sebastian/code-unit",
+            "version": "1.0.8",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/code-unit.git",
+                "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/1fc9f64c0927627ef78ba436c9b17d967e68e120",
+                "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Collection of value objects that represent the PHP code units",
+            "homepage": "https://github.com/sebastianbergmann/code-unit",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/code-unit/issues",
+                "source": "https://github.com/sebastianbergmann/code-unit/tree/1.0.8"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-10-26T13:08:54+00:00"
+        },
+        {
+            "name": "sebastian/code-unit-reverse-lookup",
+            "version": "2.0.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git",
+                "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5",
+                "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                }
+            ],
+            "description": "Looks up which function or method a line of code belongs to",
+            "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues",
+                "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/2.0.3"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-09-28T05:30:19+00:00"
+        },
+        {
+            "name": "sebastian/comparator",
+            "version": "4.0.6",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/comparator.git",
+                "reference": "55f4261989e546dc112258c7a75935a81a7ce382"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/55f4261989e546dc112258c7a75935a81a7ce382",
+                "reference": "55f4261989e546dc112258c7a75935a81a7ce382",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3",
+                "sebastian/diff": "^4.0",
+                "sebastian/exporter": "^4.0"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "4.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                },
+                {
+                    "name": "Jeff Welch",
+                    "email": "whatthejeff@gmail.com"
+                },
+                {
+                    "name": "Volker Dusch",
+                    "email": "github@wallbash.com"
+                },
+                {
+                    "name": "Bernhard Schussek",
+                    "email": "bschussek@2bepublished.at"
+                }
+            ],
+            "description": "Provides the functionality to compare PHP values for equality",
+            "homepage": "https://github.com/sebastianbergmann/comparator",
+            "keywords": [
+                "comparator",
+                "compare",
+                "equality"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/comparator/issues",
+                "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.6"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-10-26T15:49:45+00:00"
+        },
+        {
+            "name": "sebastian/complexity",
+            "version": "2.0.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/complexity.git",
+                "reference": "739b35e53379900cc9ac327b2147867b8b6efd88"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/739b35e53379900cc9ac327b2147867b8b6efd88",
+                "reference": "739b35e53379900cc9ac327b2147867b8b6efd88",
+                "shasum": ""
+            },
+            "require": {
+                "nikic/php-parser": "^4.7",
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Library for calculating the complexity of PHP code units",
+            "homepage": "https://github.com/sebastianbergmann/complexity",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/complexity/issues",
+                "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.2"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-10-26T15:52:27+00:00"
+        },
+        {
+            "name": "sebastian/diff",
+            "version": "4.0.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/diff.git",
+                "reference": "3461e3fccc7cfdfc2720be910d3bd73c69be590d"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/3461e3fccc7cfdfc2720be910d3bd73c69be590d",
+                "reference": "3461e3fccc7cfdfc2720be910d3bd73c69be590d",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3",
+                "symfony/process": "^4.2 || ^5"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "4.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                },
+                {
+                    "name": "Kore Nordmann",
+                    "email": "mail@kore-nordmann.de"
+                }
+            ],
+            "description": "Diff implementation",
+            "homepage": "https://github.com/sebastianbergmann/diff",
+            "keywords": [
+                "diff",
+                "udiff",
+                "unidiff",
+                "unified diff"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/diff/issues",
+                "source": "https://github.com/sebastianbergmann/diff/tree/4.0.4"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-10-26T13:10:38+00:00"
+        },
+        {
+            "name": "sebastian/environment",
+            "version": "5.1.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/environment.git",
+                "reference": "388b6ced16caa751030f6a69e588299fa09200ac"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/388b6ced16caa751030f6a69e588299fa09200ac",
+                "reference": "388b6ced16caa751030f6a69e588299fa09200ac",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "suggest": {
+                "ext-posix": "*"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "5.1-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                }
+            ],
+            "description": "Provides functionality to handle HHVM/PHP environments",
+            "homepage": "http://www.github.com/sebastianbergmann/environment",
+            "keywords": [
+                "Xdebug",
+                "environment",
+                "hhvm"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/environment/issues",
+                "source": "https://github.com/sebastianbergmann/environment/tree/5.1.3"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-09-28T05:52:38+00:00"
+        },
+        {
+            "name": "sebastian/exporter",
+            "version": "4.0.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/exporter.git",
+                "reference": "65e8b7db476c5dd267e65eea9cab77584d3cfff9"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/65e8b7db476c5dd267e65eea9cab77584d3cfff9",
+                "reference": "65e8b7db476c5dd267e65eea9cab77584d3cfff9",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3",
+                "sebastian/recursion-context": "^4.0"
+            },
+            "require-dev": {
+                "ext-mbstring": "*",
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "4.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                },
+                {
+                    "name": "Jeff Welch",
+                    "email": "whatthejeff@gmail.com"
+                },
+                {
+                    "name": "Volker Dusch",
+                    "email": "github@wallbash.com"
+                },
+                {
+                    "name": "Adam Harvey",
+                    "email": "aharvey@php.net"
+                },
+                {
+                    "name": "Bernhard Schussek",
+                    "email": "bschussek@gmail.com"
+                }
+            ],
+            "description": "Provides the functionality to export PHP variables for visualization",
+            "homepage": "https://www.github.com/sebastianbergmann/exporter",
+            "keywords": [
+                "export",
+                "exporter"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/exporter/issues",
+                "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.4"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2021-11-11T14:18:36+00:00"
+        },
+        {
+            "name": "sebastian/global-state",
+            "version": "5.0.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/global-state.git",
+                "reference": "23bd5951f7ff26f12d4e3242864df3e08dec4e49"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/23bd5951f7ff26f12d4e3242864df3e08dec4e49",
+                "reference": "23bd5951f7ff26f12d4e3242864df3e08dec4e49",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3",
+                "sebastian/object-reflector": "^2.0",
+                "sebastian/recursion-context": "^4.0"
+            },
+            "require-dev": {
+                "ext-dom": "*",
+                "phpunit/phpunit": "^9.3"
+            },
+            "suggest": {
+                "ext-uopz": "*"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "5.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                }
+            ],
+            "description": "Snapshotting of global state",
+            "homepage": "http://www.github.com/sebastianbergmann/global-state",
+            "keywords": [
+                "global state"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/global-state/issues",
+                "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.3"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2021-06-11T13:31:12+00:00"
+        },
+        {
+            "name": "sebastian/lines-of-code",
+            "version": "1.0.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/lines-of-code.git",
+                "reference": "c1c2e997aa3146983ed888ad08b15470a2e22ecc"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/c1c2e997aa3146983ed888ad08b15470a2e22ecc",
+                "reference": "c1c2e997aa3146983ed888ad08b15470a2e22ecc",
+                "shasum": ""
+            },
+            "require": {
+                "nikic/php-parser": "^4.6",
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Library for counting the lines of code in PHP source code",
+            "homepage": "https://github.com/sebastianbergmann/lines-of-code",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/lines-of-code/issues",
+                "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.3"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-11-28T06:42:11+00:00"
+        },
+        {
+            "name": "sebastian/object-enumerator",
+            "version": "4.0.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/object-enumerator.git",
+                "reference": "5c9eeac41b290a3712d88851518825ad78f45c71"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71",
+                "reference": "5c9eeac41b290a3712d88851518825ad78f45c71",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3",
+                "sebastian/object-reflector": "^2.0",
+                "sebastian/recursion-context": "^4.0"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "4.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                }
+            ],
+            "description": "Traverses array structures and object graphs to enumerate all referenced objects",
+            "homepage": "https://github.com/sebastianbergmann/object-enumerator/",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/object-enumerator/issues",
+                "source": "https://github.com/sebastianbergmann/object-enumerator/tree/4.0.4"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-10-26T13:12:34+00:00"
+        },
+        {
+            "name": "sebastian/object-reflector",
+            "version": "2.0.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/object-reflector.git",
+                "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7",
+                "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                }
+            ],
+            "description": "Allows reflection of object attributes, including inherited and non-public ones",
+            "homepage": "https://github.com/sebastianbergmann/object-reflector/",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/object-reflector/issues",
+                "source": "https://github.com/sebastianbergmann/object-reflector/tree/2.0.4"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-10-26T13:14:26+00:00"
+        },
+        {
+            "name": "sebastian/recursion-context",
+            "version": "4.0.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/recursion-context.git",
+                "reference": "cd9d8cf3c5804de4341c283ed787f099f5506172"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/cd9d8cf3c5804de4341c283ed787f099f5506172",
+                "reference": "cd9d8cf3c5804de4341c283ed787f099f5506172",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "4.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                },
+                {
+                    "name": "Jeff Welch",
+                    "email": "whatthejeff@gmail.com"
+                },
+                {
+                    "name": "Adam Harvey",
+                    "email": "aharvey@php.net"
+                }
+            ],
+            "description": "Provides functionality to recursively process PHP variables",
+            "homepage": "http://www.github.com/sebastianbergmann/recursion-context",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/recursion-context/issues",
+                "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.4"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-10-26T13:17:30+00:00"
+        },
+        {
+            "name": "sebastian/resource-operations",
+            "version": "3.0.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/resource-operations.git",
+                "reference": "0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8",
+                "reference": "0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "3.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                }
+            ],
+            "description": "Provides a list of PHP built-in functions that operate on resources",
+            "homepage": "https://www.github.com/sebastianbergmann/resource-operations",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/resource-operations/issues",
+                "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.3"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-09-28T06:45:17+00:00"
+        },
+        {
+            "name": "sebastian/type",
+            "version": "2.3.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/type.git",
+                "reference": "b8cd8a1c753c90bc1a0f5372170e3e489136f914"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/b8cd8a1c753c90bc1a0f5372170e3e489136f914",
+                "reference": "b8cd8a1c753c90bc1a0f5372170e3e489136f914",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.3-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Collection of value objects that represent the types of the PHP type system",
+            "homepage": "https://github.com/sebastianbergmann/type",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/type/issues",
+                "source": "https://github.com/sebastianbergmann/type/tree/2.3.4"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2021-06-15T12:49:02+00:00"
+        },
+        {
+            "name": "sebastian/version",
+            "version": "3.0.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/version.git",
+                "reference": "c6c1022351a901512170118436c764e473f6de8c"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c",
+                "reference": "c6c1022351a901512170118436c764e473f6de8c",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "3.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Library that helps with managing the version number of Git-hosted PHP projects",
+            "homepage": "https://github.com/sebastianbergmann/version",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/version/issues",
+                "source": "https://github.com/sebastianbergmann/version/tree/3.0.2"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-09-28T06:39:44+00:00"
+        },
+        {
+            "name": "theseer/tokenizer",
+            "version": "1.2.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/theseer/tokenizer.git",
+                "reference": "34a41e998c2183e22995f158c581e7b5e755ab9e"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/theseer/tokenizer/zipball/34a41e998c2183e22995f158c581e7b5e755ab9e",
+                "reference": "34a41e998c2183e22995f158c581e7b5e755ab9e",
+                "shasum": ""
+            },
+            "require": {
+                "ext-dom": "*",
+                "ext-tokenizer": "*",
+                "ext-xmlwriter": "*",
+                "php": "^7.2 || ^8.0"
+            },
+            "type": "library",
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Arne Blankerts",
+                    "email": "arne@blankerts.de",
+                    "role": "Developer"
+                }
+            ],
+            "description": "A small library for converting tokenized PHP source code into XML and potentially other formats",
+            "support": {
+                "issues": "https://github.com/theseer/tokenizer/issues",
+                "source": "https://github.com/theseer/tokenizer/tree/1.2.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/theseer",
+                    "type": "github"
+                }
+            ],
+            "time": "2021-07-28T10:34:58+00:00"
+        },
+        {
+            "name": "webmozart/assert",
+            "version": "1.10.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/webmozarts/assert.git",
+                "reference": "6964c76c7804814a842473e0c8fd15bab0f18e25"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/webmozarts/assert/zipball/6964c76c7804814a842473e0c8fd15bab0f18e25",
+                "reference": "6964c76c7804814a842473e0c8fd15bab0f18e25",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.2 || ^8.0",
+                "symfony/polyfill-ctype": "^1.8"
+            },
+            "conflict": {
+                "phpstan/phpstan": "<0.12.20",
+                "vimeo/psalm": "<4.6.1 || 4.6.2"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^8.5.13"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.10-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Webmozart\\Assert\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Bernhard Schussek",
+                    "email": "bschussek@gmail.com"
+                }
+            ],
+            "description": "Assertions to validate method input/output with nice error messages.",
+            "keywords": [
+                "assert",
+                "check",
+                "validate"
+            ],
+            "support": {
+                "issues": "https://github.com/webmozarts/assert/issues",
+                "source": "https://github.com/webmozarts/assert/tree/1.10.0"
+            },
+            "time": "2021-03-09T10:59:23+00:00"
+        }
+    ],
+    "aliases": [],
+    "minimum-stability": "stable",
+    "stability-flags": [],
+    "prefer-stable": false,
+    "prefer-lowest": false,
+    "platform": [],
+    "platform-dev": [],
+    "plugin-api-version": "2.1.0"
+}

+ 33 - 53
core/Objects/AesStream.class.php

@@ -24,11 +24,11 @@ class AesStream {
     }
   }
 
-  public function setInput($file) {
+  public function setInputFile(string $file) {
     $this->inputFile = $file;
   }
 
-  public function setOutput($callback) {
+  public function setOutput(callable $callback) {
     $this->callback = $callback;
   }
 
@@ -36,51 +36,13 @@ class AesStream {
     $this->outputFile = $file;
   }
 
-  private function add(string $a, int $b): string {
-    // counter $b is n = PHP_INT_SIZE bytes large
-    $b_arr = pack('I', $b);
-    $b_size = strlen($b_arr);
-    $a_size = strlen($a);
-
-    $prefix = "";
-    if ($a_size > $b_size) {
-      $prefix = substr($a, 0, $a_size - $b_size);
-    }
-
-    // xor last n bytes of $a with $b
-    $xor = substr($a, strlen($prefix), $b_size);
-    if (strlen($xor) !== strlen($b_arr)) {
-      var_dump($xor);
-      var_dump($b_arr);
-      die();
-    }
-    $xor = $this->xor($xor, $b_arr);
-    return $prefix . $xor;
-  }
-
-  private function xor(string $a, string $b): string {
-    $arr_a = str_split($a);
-    $arr_b = str_split($b);
-    if (strlen($a) !== strlen($b)) {
-      var_dump($a);
-      var_dump($b);
-      var_dump(range(0, strlen($a) - 1));
-      die();
-    }
-
-    return implode("", array_map(function($i) use ($arr_a, $arr_b) {
-      return chr(ord($arr_a[$i]) ^ ord($arr_b[$i]));
-    }, range(0, strlen($a) - 1)));
-  }
-
   public function start(): bool {
     if (!$this->inputFile) {
       return false;
     }
 
     $blockSize = 16;
-    $bitStrength = strlen($this->key) * 8;
-    $aesMode   = "AES-$bitStrength-ECB";
+    $aesMode   = $this->getCipherMode();
 
     $outputHandle = null;
     $inputHandle = fopen($this->inputFile, "rb");
@@ -91,25 +53,30 @@ class AesStream {
     if ($this->outputFile !== null) {
       $outputHandle = fopen($this->outputFile, "wb");
       if (!$outputHandle) {
+        fclose($inputHandle);
         return false;
       }
     }
 
-    $counter = 0;
+    set_time_limit(0);
+
+    $ivCounter = $this->iv;
+    $modulo = \gmp_init("0x1" . str_repeat("00", $blockSize), 16);
+
     while (!feof($inputHandle)) {
-      $chunk = fread($inputHandle, 4096);
+      $chunk = fread($inputHandle, 65536);
       $chunkSize = strlen($chunk);
-      for ($offset = 0; $offset < $chunkSize; $offset += $blockSize) {
-        $block = substr($chunk, $offset, $blockSize);
-        if (strlen($block) !== $blockSize) {
-          $padding = ($blockSize - strlen($block));
-          $block .= str_repeat(chr($padding), $padding);
-        }
+      if ($chunkSize > 0) {
+        $blockCount = intval(ceil($chunkSize / $blockSize));
+        $encrypted = openssl_encrypt($chunk, $aesMode, $this->key, OPENSSL_RAW_DATA | OPENSSL_NO_PADDING, $ivCounter);
 
-        $ivCounter = $this->add($this->iv, $counter + 1);
-        $encrypted = substr(openssl_encrypt($ivCounter, $aesMode, $this->key, OPENSSL_RAW_DATA), 0, $blockSize);
-        $encrypted = $this->xor($encrypted, $block);
-        if (is_callable($this->callback)) {
+        $ivNumber = \gmp_init(bin2hex($ivCounter), 16);
+        $ivNumber = \gmp_add($ivNumber, $blockCount);
+        $ivNumber = \gmp_mod($ivNumber, $modulo);
+        $ivNumber = str_pad(\gmp_strval($ivNumber, 16), $blockSize * 2, "0", STR_PAD_LEFT);
+        $ivCounter = hex2bin($ivNumber);
+
+        if ($this->callback !== null) {
           call_user_func($this->callback, $encrypted);
         }
 
@@ -123,4 +90,17 @@ class AesStream {
     if ($outputHandle) fclose($outputHandle);
     return true;
   }
+
+  public function getCipherMode(): string {
+    $bitStrength = strlen($this->key) * 8;
+    return "aes-$bitStrength-ctr";
+  }
+
+  public function getKey(): string {
+    return $this->key;
+  }
+
+  public function getIV(): string {
+    return $this->iv;
+  }
 }

+ 0 - 3
core/Objects/Language.class.php

@@ -27,9 +27,6 @@ namespace Objects {
     public function getCode(): string { return $this->langCode; }
     public function getShortCode() { return substr($this->langCode, 0, 2); }
     public function getName() { return $this->langName; }
-    public function getIconPath() { return "/img/icons/lang/$this->langCode.gif"; }
-    public function getEntries() { return $this->entries; }
-    public function getModules() { return $this->modules; }
 
       /**
        * @param $module LanguageModule class or object

+ 18 - 12
core/Objects/Session.class.php

@@ -4,6 +4,7 @@ namespace Objects;
 
 use DateTime;
 use \Driver\SQL\Condition\Compare;
+use Driver\SQL\Expression\CurrentTimeStamp;
 use Exception;
 use External\JWT;
 
@@ -118,7 +119,7 @@ class Session extends ApiObject {
     return false;
   }
 
-  public function destroy() {
+  public function destroy(): bool {
     return $this->user->getSQL()->update("Session")
       ->set("active", false)
       ->where(new Compare("Session.uid", $this->sessionId))
@@ -126,21 +127,26 @@ class Session extends ApiObject {
       ->execute();
   }
 
-  public function update() {
+  public function update(): bool {
     $this->updateMetaData();
     $minutes = Session::DURATION;
 
     $sql = $this->user->getSQL();
-    return $sql->update("Session")
-      ->set("Session.expires", (new DateTime())->modify("+$minutes minute"))
-      ->set("Session.ipAddress", $this->ipAddress)
-      ->set("Session.os", $this->os)
-      ->set("Session.browser", $this->browser)
-      ->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()))
-      ->execute();
+    return
+      $sql->update("User")
+        ->set("last_online", new CurrentTimeStamp())
+        ->where(new Compare("uid", $this->user->getId()))
+        ->execute() &&
+      $sql->update("Session")
+        ->set("Session.expires", (new DateTime())->modify("+$minutes minute"))
+        ->set("Session.ipAddress", $this->ipAddress)
+        ->set("Session.os", $this->os)
+        ->set("Session.browser", $this->browser)
+        ->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()))
+        ->execute();
   }
 
   public function getCsrfToken(): string {

+ 22 - 10
core/Objects/User.class.php

@@ -17,7 +17,9 @@ class User extends ApiObject {
   private ?Session $session;
   private int $uid;
   private string $username;
+  private string $fullName;
   private ?string $email;
+  private ?string $profilePicture;
   private Language $language;
   private array $groups;
 
@@ -55,6 +57,7 @@ class User extends ApiObject {
   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; }
@@ -63,6 +66,7 @@ class User extends ApiObject {
   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 getProfilePicture() : ?string { return $this->profilePicture; }
 
   public function __debugInfo(): array {
     $debugInfo = array(
@@ -83,6 +87,8 @@ class User extends ApiObject {
       return array(
         'uid' => $this->uid,
         'name' => $this->username,
+        'fullName' => $this->fullName,
+        'profilePicture' => $this->profilePicture,
         'email' => $this->email,
         'groups' => $this->groups,
         'language' => $this->language->jsonSerialize(),
@@ -99,8 +105,10 @@ class User extends ApiObject {
     $this->uid = 0;
     $this->username = '';
     $this->email = '';
+    $this->groups = [];
     $this->loggedIn = false;
     $this->session = null;
+    $this->profilePicture = null;
   }
 
   public function logout(): bool {
@@ -137,9 +145,9 @@ 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, $sessionUpdate = true): bool {
+  public function readData($userId, $sessionId, bool $sessionUpdate = true): bool {
 
-    $res = $this->sql->select("User.name", "User.email",
+    $res = $this->sql->select("User.name", "User.email", "User.fullName", "User.profilePicture",
         "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")
@@ -162,7 +170,10 @@ class User extends ApiObject {
         $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"] ?? '{}'));
         $this->session->stayLoggedIn($this->sql->parseBool(["stay_logged_in"]));
@@ -183,16 +194,14 @@ class User extends ApiObject {
   }
 
   private function parseCookies() {
-    if(isset($_COOKIE['session'])
-      && is_string($_COOKIE['session'])
-      && !empty($_COOKIE['session'])) {
+    if(isset($_COOKIE['session']) && is_string($_COOKIE['session']) && !empty($_COOKIE['session'])) {
       try {
         $token = $_COOKIE['session'];
         $settings = $this->configuration->getSettings();
         $decoded = (array)JWT::decode($token, $settings->getJwtSecret());
         if(!is_null($decoded)) {
-          $userId = (isset($decoded['userId']) ? $decoded['userId'] : NULL);
-          $sessionId = (isset($decoded['sessionId']) ? $decoded['sessionId'] : NULL);
+          $userId = ($decoded['userId'] ?? NULL);
+          $sessionId = ($decoded['sessionId'] ?? NULL);
           if(!is_null($userId) && !is_null($sessionId)) {
             $this->readData($userId, $sessionId);
           }
@@ -226,7 +235,8 @@ class User extends ApiObject {
       return true;
     }
 
-    $res = $this->sql->select("ApiKey.user_id as uid", "User.name", "User.email", "User.confirmed",
+    $res = $this->sql->select("ApiKey.user_id as uid", "User.name", "User.fullName", "User.email",
+      "User.confirmed", "User.profilePicture",
       "Language.uid as langId", "Language.code as langCode", "Language.name as langName",
       "Group.uid as groupId", "Group.name as groupName")
       ->from("ApiKey")
@@ -240,8 +250,8 @@ class User extends ApiObject {
       ->execute();
 
     $success = ($res !== FALSE);
-    if($success) {
-      if(empty($res)) {
+    if ($success) {
+      if (empty($res) || !is_array($res)) {
         $success = false;
       } else {
         $row = $res[0];
@@ -251,7 +261,9 @@ class User extends ApiObject {
 
         $this->uid = $row['uid'];
         $this->username = $row['name'];
+        $this->fullName = $row["fullName"];
         $this->email = $row['email'];
+        $this->profilePicture = $row["profilePicture"];
 
         if(!is_null($row['langId'])) {
           $this->setLanguage(Language::newInstance($row['langId'], $row['langCode'], $row['langName']));

+ 0 - 0
core/TemplateCache/.gitkeep


+ 4 - 0
core/Templates/404.twig

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

+ 36 - 0
core/Templates/account.twig

@@ -0,0 +1,36 @@
+{% extends "base.twig" %}
+
+{% block head %}
+    <script src="/js/jquery.min.js" nonce="{{ site.csp.nonce }}"></script>
+    <script src="/js/script.js" nonce="{{ site.csp.nonce }}"></script>
+    <script src="/js/account.js" nonce="{{ site.csp.nonce }}"></script>
+    <link rel="stylesheet" href="/css/bootstrap.min.css" nonce="{{ site.csp.nonce }}">
+    <script src="/js/bootstrap.bundle.min.js" nonce="{{ site.csp.nonce }}"></script>
+    <link rel="stylesheet" href="/css/fontawesome.min.css" nonce="{{ site.csp.nonce }}">
+    <link rel="stylesheet" href="/css/account.css" nonce="{{ site.csp.nonce }}">
+    <title>Account - {{ view_title }}</title>
+    {% if site.recaptcha.enabled %}
+        <script src="https://www.google.com/recaptcha/api.js?render={{ site.recaptcha.key }}" nonce="{{ site.csp.nonce }}"></script>
+    {% endif %}
+{% endblock %}
+
+{% block body %}
+    <div class="container mt-5">
+        <div class="row">
+            <div class="col-md-3 py-5 bg-primary text-white text-center" style='border-top-left-radius:.4em;border-bottom-left-radius:.4em;margin-left: auto'>
+                <div class="card-body">
+                    <i class="fas fa-{{ view_icon }} fa-3x"></i>
+                    <h2 class="py-3">{{ view_title }}</h2>
+                    <p>{{ view_description }}</p>
+                </div>
+            </div>
+            <div class="col-md-5 pt-5 pb-2 border border-info" style='border-top-right-radius:.4em;border-bottom-right-radius:.4em;margin-right:auto'>
+                {% block view_content %}{% endblock %}
+                <div class='alert mt-2' style='display:none' id='alertMessage'></div>
+            </div>
+        </div>
+    </div>
+    {% if site.recaptcha.enabled %}
+        <input type='hidden' value='{{ site.recaptcha.key }}' id='siteKey' />
+    {% endif %}
+{% endblock %}

+ 46 - 0
core/Templates/account/accept_invite.twig

@@ -0,0 +1,46 @@
+{% extends "account.twig" %}
+
+{% set view_title = 'Invitation' %}
+{% set view_icon = 'user-check' %}
+{% set view_description = 'Finnish your account registration by choosing a password.' %}
+
+{% block view_content %}
+
+    {% if not view.success %}
+        <div class="alert alert-danger" role="alert">{{ view.message }}</div>
+        <a href='/login' class='btn btn-primary'>Back to login</a>
+    {% else %}
+        <h4 class="pb-4">Please fill with your details</h4>
+        <form>
+            <input name='token' id='token' type='hidden' value='{{ view.token }}'/>
+            <div class="input-group">
+                <div class="input-group-append">
+                    <span class="input-group-text"><i class="fas fa-hashtag"></i></span>
+                </div>
+                <input id="username" name="username" placeholder="Username" class="form-control" type="text" maxlength="32" value='{{ view.invited_user.name }}' disabled>
+            </div>
+            <div class="input-group mt-3">
+            <div class="input-group-append">
+                <span class="input-group-text"><i class="fas fa-at"></i></span>
+            </div>
+                <input type="email" name='email' id='email' class="form-control" placeholder="Email" maxlength="64" value='{{ view.invited_user.email }}' disabled>
+            </div>
+            <div class="input-group mt-3">
+            <div class="input-group-append">
+                <span class="input-group-text"><i class="fas fa-key"></i></span>
+            </div>
+                <input type="password" autocomplete='new-password' name='password' id='password' class="form-control" placeholder="Password">
+            </div>
+            <div class="input-group mt-3">
+            <div class="input-group-append">
+                <span class="input-group-text"><i class="fas fa-key"></i></span>
+            </div>
+                <input type="password" autocomplete='new-password' name='confirmPassword' id='confirmPassword' class="form-control" placeholder="Confirm Password">
+            </div>
+            <div class="input-group mt-3">
+            <button type="button" class="btn btn-success" id='btnAcceptInvite'>Submit</button>
+            </div>
+        </form>
+    {% endif %}
+
+{% endblock %}

+ 37 - 0
core/Templates/account/confirm_email.twig

@@ -0,0 +1,37 @@
+{% extends "account.twig" %}
+
+{% set view_title = 'Confirm Email' %}
+{% set view_icon = 'user-check' %}
+{% set view_description = 'Request a password reset, once you got the e-mail address, you can choose a new password' %}
+
+{% block view_content %}
+    <noscript>
+        <div class="alert alert-danger">Javascript is required</div>
+    </noscript>
+    <div class="alert alert-info" id="confirm-status">
+        Confirming email… <i class="fas fa-spinner fa-spin"></i>
+    </div>
+    <a href='/login'><button class='btn btn-primary' style='position: absolute; bottom: 10px' type='button'>Proceed to Login</button></a>
+    <script nonce="{{ site.csp.nonce }}">
+        $(document).ready(function() {
+            let token = jsCore.getParameter("token");
+            let confirmStatus = $("#confirm-status");
+            if (token) {
+                jsCore.apiCall("/user/confirmEmail", { token: token }, (res) => {
+                    confirmStatus.removeClass("alert-info");
+                    if (!res.success) {
+                        confirmStatus.addClass("alert-danger");
+                        confirmStatus.text("Error confirming e-mail address: " + res.msg);
+                    } else {
+                        confirmStatus.addClass("alert-success");
+                        confirmStatus.text("Your e-mail address was successfully confirmed, you may now log in.");
+                    }
+                });
+            } else {
+                confirmStatus.removeClass("alert-info");
+                confirmStatus.addClass("alert-danger");
+                confirmStatus.text("The link you visited is no longer valid");
+            }
+        });
+    </script>
+{% endblock %}

+ 50 - 0
core/Templates/account/register.twig

@@ -0,0 +1,50 @@
+{% extends "account.twig" %}
+
+{% set view_title = 'Registration' %}
+{% set view_icon = 'user-plus' %}
+{% set view_description = 'Create a new account' %}
+
+{% block view_content %}
+
+    {% if not view.success %}
+        <div class="alert alert-danger" role="alert">{{ view.message }}</div>
+        <a href='/login' class='btn btn-primary'>Go back</a>
+    {% else %}
+        <h4 class="pb-4">Please fill with your details</h4>
+        <form>
+            <div class="input-group">
+                <div class="input-group-append">
+                    <span class="input-group-text"><i class="fas fa-hashtag"></i></span>
+                </div>
+                <input id="username" autocomplete='username' name="username" placeholder="Username" class="form-control" type="text" maxlength="32">
+            </div>
+            <div class="input-group mt-3">
+            <div class="input-group-append">
+                <span class="input-group-text"><i class="fas fa-at"></i></span>
+            </div>
+                <input type="email" autocomplete='email' name='email' id='email' class="form-control" placeholder="Email" maxlength="64">
+            </div>
+            <div class="input-group mt-3">
+            <div class="input-group-append">
+                <span class="input-group-text"><i class="fas fa-key"></i></span>
+            </div>
+                <input type="password" autocomplete='new-password' name='password' id='password' class="form-control" placeholder="Password">
+            </div>
+            <div class="input-group mt-3">
+            <div class="input-group-append">
+                <span class="input-group-text"><i class="fas fa-key"></i></span>
+            </div>
+                <input type="password" autocomplete='new-password' name='confirmPassword' id='confirmPassword' class="form-control" placeholder="Confirm Password">
+            </div>
+            <div class="input-group mt-3">
+            <button type="button" class="btn btn-primary" id='btnRegister'>Submit</button>
+            <a href='/login' style='margin-left: 10px'>
+                <button class='btn btn-secondary' type='button'>
+                    Back to Login
+                </button>
+            </a>
+            </div>
+        </form>
+    {% endif %}
+
+{% endblock %}

+ 27 - 0
core/Templates/account/resend_confirm_email.twig

@@ -0,0 +1,27 @@
+{% extends "account.twig" %}
+
+{% set view_title = 'Resend Confirm Email' %}
+{% set view_icon = 'envelope' %}
+{% set view_description = 'Request a new confirmation email to finalize the account creation' %}
+
+{% block view_content %}
+    <p class='lead'>Enter your E-Mail address, to receive a new e-mail to confirm your registration.</p>
+    <form>
+        <div class="input-group">
+            <div class="input-group-append">
+                <span class="input-group-text"><i class="fas fa-at"></i></span>
+            </div>
+            <input id="email" autocomplete='email' name="email" placeholder="E-Mail address" class="form-control" type="email" maxlength="64" />
+        </div>
+        <div class="input-group mt-2" style='position: absolute;bottom: 15px'>
+            <button id='btnResendConfirmEmail' class='btn btn-primary'>
+                Request
+            </button>
+            <a href='/login' style='margin-left: 10px'>
+                <button class='btn btn-secondary' type='button'>
+                    Back to Login
+                </button>
+            </a>
+        </div>
+    </form>
+{% endblock %}

+ 59 - 0
core/Templates/account/reset_password.twig

@@ -0,0 +1,59 @@
+{% extends "account.twig" %}
+
+{% set view_title = 'Reset Password' %}
+{% set view_icon = 'user-lock' %}
+{% set view_description = 'Request a password reset, once you got the e-mail address, you can choose a new password' %}
+
+{% block view_content %}
+    {% if view.token %}
+        {% if not view.success %}
+            <div class="alert alert-danger" role="alert">{{ view.message }}</div>
+            <a href='/resetPassword' class='btn btn-primary'>Go back</a>
+        {% else %}
+            <h4 class="pb-4">Choose a new password</h4>
+            <form>
+                <input name='token' id='token' type='hidden' value='{{ view.token }}'/>
+                <div class="input-group mt-3">
+                    <div class="input-group-append">
+                        <span class="input-group-text"><i class="fas fa-key"></i></span>
+                    </div>
+                    <input type="password" autocomplete='new-password' name='password' id='password' class="form-control" placeholder="Password">
+                </div>
+                <div class="input-group mt-3">
+                    <div class="input-group-append">
+                        <span class="input-group-text"><i class="fas fa-key"></i></span>
+                    </div>
+                    <input type="password" autocomplete='new-password' name='confirmPassword' id='confirmPassword' class="form-control" placeholder="Confirm Password">
+                </div>
+                <div class="input-group mt-3">
+                    <button type="button" class="btn btn-primary" id='btnResetPassword'>Submit</button>
+                    <a href='/login' style='margin-left: 10px; display: none' id='backToLogin'>
+                        <button class='btn btn-success' type='button'>
+                            Back to Login
+                        </button>
+                    </a>
+                </div>
+            </form>
+        {% endif %}
+    {% else %}
+        <p class='lead'>Enter your E-Mail address, to receive a password reset token.</p>
+        <form>
+            <div class="input-group">
+                <div class="input-group-append">
+                    <span class="input-group-text"><i class="fas fa-at"></i></span>
+                </div>
+                <input id="email" autocomplete='email' name="email" placeholder="E-Mail address" class="form-control" type="email" maxlength="64" />
+            </div>
+            <div class="input-group mt-2" style='position: absolute;bottom: 15px'>
+                <button id='btnRequestPasswordReset' class='btn btn-primary'>
+                    Request
+                </button>
+                <a href='/login' style='margin-left: 10px'>
+                    <button class='btn btn-secondary' type='button'>
+                        Back to Login
+                    </button>
+                </a>
+            </div>
+        </form>
+    {% endif %}
+{% endblock %}

+ 12 - 0
core/Templates/admin.twig

@@ -0,0 +1,12 @@
+{% extends "base.twig" %}
+
+{% block head %}
+    <title>{{ site.name }} - Administration</title>
+    <script src="/js/fontawesome-all.min.js" nonce="{{ site.csp.nonce }}"></script>
+{% endblock %}
+
+{% block body %}
+    <noscript>You need Javascript enabled to run this app</noscript>
+    <div class="wrapper" id="root"></div>
+    <script src="/js/admin.min.js" nonce="{{ site.csp.nonce }}"></script>
+{% endblock %}

+ 15 - 0
core/Templates/base.twig

@@ -0,0 +1,15 @@
+<!DOCTYPE html>
+<html lang="{{ user.lang }}">
+  <head>
+    <meta charset="utf-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <meta name="format-detection" content="telephone=yes" />
+    {% block head %}
+      <title>{{ site.title }}</title>
+    {% endblock %}
+  </head>
+  <body>
+    {% block body %}
+    {% endblock %}
+  </body>
+</html>

+ 8 - 0
core/Templates/mail/accept_invite.twig

@@ -0,0 +1,8 @@
+Hello {{ username }},<br><br>
+You were invited to create an account on {{ site_name }}. Please click on the following link to confirm your email address and complete your registration by choosing a new password.
+If you want to decline the invitation, you can simply ignore this email. The link is valid for {{ valid_time }}:<br><br>
+
+<a href="{{ link }}">{{ link }}</a><br><br>
+
+Best Regards<br>
+{{ site_name }} Administration

+ 8 - 0
core/Templates/mail/confirm_email.twig

@@ -0,0 +1,8 @@
+Hello {{ username }},<br><br>
+You recently created an account on {{ site_name }}. Please click on the following link to confirm your email address and complete your registration.<br>
+If you haven't registered an account, you can simply ignore this email. The link is valid for {{ valid_time }}:<br><br>
+
+<a href="{{ link }}">{{ link }}</a><br><br>
+
+Best Regards<br>
+{{ site_name }} Administration

+ 8 - 0
core/Templates/mail/reset_password.twig

@@ -0,0 +1,8 @@
+Hello {{ username }},<br>
+you requested a password reset on {{ site_name }}. Please click on the following link to choose a new password. <br>
+If this request was not intended, you can simply ignore the email. The Link is valid for {{ valid_time }}:<br><br>
+
+<a href="{{ link }}">{{ link }}</a><br><br>
+
+Best Regards<br>
+{{ site_name }} Administration

+ 7 - 0
core/Templates/redirect.twig

@@ -0,0 +1,7 @@
+{% extends "base.twig" %}
+{% block head %}
+    <meta http-equiv="refresh" content="0;url={{ url }}">
+{% endblock %}
+{% block body %}
+    You will be automatically redirected to <b>{{ url }}</b>. If that doesn't work, click <a href="{{ url }}">here</a>.
+{% endblock %}

+ 0 - 89
core/Views/Account/AcceptInvite.class.php

@@ -1,89 +0,0 @@
-<?php
-
-
-namespace Views\Account;
-
-
-use Elements\Document;
-use Elements\View;
-
-class AcceptInvite extends AccountView {
-
-  private bool $success;
-  private string $message;
-  private array $invitedUser;
-
-  public function __construct(Document $document, $loadView = true) {
-    parent::__construct($document, $loadView);
-    $this->title = "Invitation";
-    $this->description = "Finnish your account registration by choosing a password.";
-    $this->icon = "user-check";
-    $this->success = false;
-    $this->message = "No content";
-    $this->invitedUser = array();
-  }
-
-  public function loadView() {
-    parent::loadView();
-
-    if (isset($_GET["token"]) && is_string($_GET["token"]) && !empty($_GET["token"])) {
-      $req = new \Api\User\CheckToken($this->getDocument()->getUser());
-      $this->success = $req->execute(array("token" => $_GET["token"]));
-      if ($this->success) {
-        if (strcmp($req->getResult()["token"]["type"], "invite") !== 0) {
-          $this->success = false;
-          $this->message = "The given token has a wrong type.";
-        } else {
-          $this->invitedUser = $req->getResult()["user"];
-        }
-      } else {
-        $this->message = "Error confirming e-mail address: " . $req->getLastError();
-      }
-    } else {
-      $this->success = false;
-      $this->message = "The link you visited is no longer valid";
-    }
-  }
-
-  protected function getAccountContent() {
-    if (!$this->success) {
-      return $this->createErrorText($this->message);
-    }
-
-    $token = htmlspecialchars($_GET["token"], ENT_QUOTES);
-    $username = $this->invitedUser["name"];
-    $emailAddress = $this->invitedUser["email"];
-
-    return "<h4 class=\"pb-4\">Please fill with your details</h4>
-      <form>
-        <input name='token' id='token' type='hidden' value='$token'/>
-        <div class=\"input-group\">
-          <div class=\"input-group-append\">
-            <span class=\"input-group-text\"><i class=\"fas fa-hashtag\"></i></span>  
-          </div>
-          <input id=\"username\" name=\"username\" placeholder=\"Username\" class=\"form-control\" type=\"text\" maxlength=\"32\" value='$username' disabled>
-        </div>
-        <div class=\"input-group mt-3\">
-         <div class=\"input-group-append\">
-            <span class=\"input-group-text\"><i class=\"fas fa-at\"></i></span>
-          </div>
-          <input type=\"email\" name='email' id='email' class=\"form-control\" placeholder=\"Email\" maxlength=\"64\" value='$emailAddress' disabled>
-        </div>
-        <div class=\"input-group mt-3\">
-          <div class=\"input-group-append\">
-            <span class=\"input-group-text\"><i class=\"fas fa-key\"></i></span>
-          </div>
-          <input type=\"password\" autocomplete='new-password' name='password' id='password' class=\"form-control\" placeholder=\"Password\">
-        </div>
-        <div class=\"input-group mt-3\">
-          <div class=\"input-group-append\">
-            <span class=\"input-group-text\"><i class=\"fas fa-key\"></i></span>
-          </div>
-          <input type=\"password\" autocomplete='new-password' name='confirmPassword' id='confirmPassword' class=\"form-control\" placeholder=\"Confirm Password\">
-        </div>
-        <div class=\"input-group mt-3\">
-          <button type=\"button\" class=\"btn btn-success\" id='btnAcceptInvite'>Submit</button>
-        </div>
-     </form>";
-  }
-}

+ 0 - 61
core/Views/Account/AccountView.class.php

@@ -1,61 +0,0 @@
-<?php
-
-namespace Views\Account;
-
-use Elements\Document;
-use Elements\View;
-
-abstract class AccountView extends View {
-
-  protected string $description;
-  protected string $icon;
-
-  public function __construct(Document $document, $loadView = true) {
-    parent::__construct($document, $loadView);
-    $this->description = "";
-    $this->icon = "image";
-  }
-
-  public function loadView() {
-    parent::loadView();
-
-    $document = $this->getDocument();
-    $settings = $document->getUser()->getConfiguration()->getSettings();
-    if ($settings->isRecaptchaEnabled()) {
-      $document->getHead()->loadGoogleRecaptcha($settings->getRecaptchaSiteKey());
-    }
-  }
-
-  public function getCode(): string {
-    $html = parent::getCode();
-
-    $content = $this->getAccountContent();
-    $icon = $this->createIcon($this->icon, "fas", "fa-3x");
-
-    $html .= "<div class=\"container mt-5\">
-        <div class=\"row\">
-          <div class=\"col-md-3 py-5 bg-primary text-white text-center\" style='border-top-left-radius:.4em;border-bottom-left-radius:.4em;margin-left: auto'>
-            <div class=\"card-body\">
-              $icon
-              <h2 class=\"py-3\">$this->title</h2>
-              <p>$this->description</p>
-            </div>
-          </div>
-          <div class=\"col-md-5 pt-5 pb-2 border border-info\" style='border-top-right-radius:.4em;border-bottom-right-radius:.4em;margin-right:auto'>
-            $content
-            <div class='alert mt-2' style='display:none' id='alertMessage'></div>
-          </div>
-        </div>
-      </div>";
-
-    $settings = $this->getDocument()->getUser()->getConfiguration()->getSettings();
-    if ($settings->isRecaptchaEnabled()) {
-      $siteKey = $settings->getRecaptchaSiteKey();
-      $html .= "<input type='hidden' value='$siteKey' id='siteKey' />";
-    }
-
-    return $html;
-  }
-
-  protected abstract function getAccountContent();
-}

+ 0 - 55
core/Views/Account/ConfirmEmail.class.php

@@ -1,55 +0,0 @@
-<?php
-
-
-namespace Views\Account;
-
-
-use Elements\Document;
-use Elements\Script;
-
-class ConfirmEmail extends AccountView {
-
-  public function __construct(Document $document, $loadView = true) {
-    parent::__construct($document, $loadView);
-    $this->title = "Confirm Email";
-    $this->description = "Request a password reset, once you got the e-mail address, you can choose a new password";
-    $this->icon = "user-check";
-  }
-
-  public function loadView() {
-    parent::loadView();
-    $this->getDocument()->getHead()->addScript(Script::MIME_TEXT_JAVASCRIPT, "", '
-      $(document).ready(function() {
-         var token = jsCore.getParameter("token");
-         if (token) {
-           jsCore.apiCall("/user/confirmEmail", { token: token }, (res) => {
-              $("#confirm-status").removeClass("alert-info");
-              if (!res.success) {
-                  $("#confirm-status").addClass("alert-danger");
-                  $("#confirm-status").text("Error confirming e-mail address: " + res.msg);
-              } else {
-                  $("#confirm-status").addClass("alert-success");
-                  $("#confirm-status").text("Your e-mail address was successfully confirmed, you may now log in.");
-              }
-          });
-        } else {
-          $("#confirm-status").removeClass("alert-info");
-          $("#confirm-status").addClass("alert-danger");
-          $("#confirm-status").text("The link you visited is no longer valid");
-        }
-      });'
-    );
-  }
-
-  protected function getAccountContent() {
-
-    $spinner = $this->createIcon("spinner");
-    $html = "<noscript><div class=\"alert alert-danger\">Javascript is required</div></noscript>
-             <div class=\"alert alert-info\" id=\"confirm-status\">
-                Confirming email… $spinner
-             </div>";
-
-    $html .= "<a href='/login'><button class='btn btn-primary' style='position: absolute; bottom: 10px' type='button'>Proceed to Login</button></a>";
-    return $html;
-  }
-}

+ 0 - 70
core/Views/Account/Register.class.php

@@ -1,70 +0,0 @@
-<?php
-
-
-namespace Views\Account;
-
-use Elements\Document;
-
-class Register extends AccountView {
-
-  public function __construct(Document $document, $loadView = true) {
-    parent::__construct($document, $loadView);
-    $this->title = "Registration";
-    $this->description = "Create a new account";
-    $this->icon = "user-plus";
-  }
-
-  public function getAccountContent() {
-
-    $user = $this->getDocument()->getUser();
-    if ($user->isLoggedIn()) {
-      header(302);
-      header("Location: /");
-      die("You are already logged in.");
-    }
-
-    $settings = $user->getConfiguration()->getSettings();
-    if (!$settings->isRegistrationAllowed()) {
-      return $this->createErrorText(
-        "Registration is not enabled on this website. If you are an administrator,
-        goto <a href=\"/admin/settings\">/admin/settings</a>, to enable the user registration"
-      );
-    }
-
-    return "<h4 class=\"pb-4\">Please fill with your details</h4>
-      <form>
-        <div class=\"input-group\">
-          <div class=\"input-group-append\">
-            <span class=\"input-group-text\"><i class=\"fas fa-hashtag\"></i></span>  
-          </div>
-          <input id=\"username\" autocomplete='username' name=\"username\" placeholder=\"Username\" class=\"form-control\" type=\"text\" maxlength=\"32\">
-        </div>
-        <div class=\"input-group mt-3\">
-         <div class=\"input-group-append\">
-            <span class=\"input-group-text\"><i class=\"fas fa-at\"></i></span>
-          </div>
-          <input type=\"email\" autocomplete='email' name='email' id='email' class=\"form-control\" placeholder=\"Email\" maxlength=\"64\">
-        </div>
-        <div class=\"input-group mt-3\">
-          <div class=\"input-group-append\">
-            <span class=\"input-group-text\"><i class=\"fas fa-key\"></i></span>
-          </div>
-          <input type=\"password\" autocomplete='new-password' name='password' id='password' class=\"form-control\" placeholder=\"Password\">
-        </div>
-        <div class=\"input-group mt-3\">
-          <div class=\"input-group-append\">
-            <span class=\"input-group-text\"><i class=\"fas fa-key\"></i></span>
-          </div>
-          <input type=\"password\" autocomplete='new-password' name='confirmPassword' id='confirmPassword' class=\"form-control\" placeholder=\"Confirm Password\">
-        </div>
-        <div class=\"input-group mt-3\">
-          <button type=\"button\" class=\"btn btn-primary\" id='btnRegister'>Submit</button>          
-          <a href='/login' style='margin-left: 10px'>
-            <button class='btn btn-secondary' type='button'>
-              Back to Login
-            </button>
-          </a>
-        </div>
-     </form>";
-  }
-}

+ 0 - 39
core/Views/Account/ResendConfirmEmail.class.php

@@ -1,39 +0,0 @@
-<?php
-
-
-namespace Views\Account;
-
-
-use Elements\Document;
-
-class ResendConfirmEmail extends AccountView {
-
-  public function __construct(Document $document, $loadView = true) {
-    parent::__construct($document, $loadView);
-    $this->title = "Resend Confirm Email";
-    $this->description = "Request a new confirmation email to finalize the account creation";
-    $this->icon = "envelope";
-  }
-
-  protected function getAccountContent() {
-    return  "<p class='lead'>Enter your E-Mail address, to receive a new e-mail to confirm your registration.</p>
-          <form>
-        <div class=\"input-group\">
-          <div class=\"input-group-append\">
-            <span class=\"input-group-text\"><i class=\"fas fa-at\"></i></span>  
-          </div>
-          <input id=\"email\" autocomplete='email' name=\"email\" placeholder=\"E-Mail address\" class=\"form-control\" type=\"email\" maxlength=\"64\" />
-        </div>
-        <div class=\"input-group mt-2\" style='position: absolute;bottom: 15px'>
-          <button id='btnResendConfirmEmail' class='btn btn-primary'>
-            Request
-          </button>
-          <a href='/login' style='margin-left: 10px'>
-            <button class='btn btn-secondary' type='button'>
-              Back to Login
-            </button>
-          </a>
-        </div>
-      ";
-  }
-}

+ 0 - 94
core/Views/Account/ResetPassword.class.php

@@ -1,94 +0,0 @@
-<?php
-
-
-namespace Views\Account;
-
-
-use Elements\Document;
-
-class ResetPassword extends AccountView {
-
-  private bool $success;
-  private string $message;
-  private ?string $token;
-
-  public function __construct(Document $document, $loadView = true) {
-    parent::__construct($document, $loadView);
-    $this->title = "Reset Password";
-    $this->description = "Request a password reset, once you got the e-mail address, you can choose a new password";
-    $this->icon = "user-lock";
-    $this->success = true;
-    $this->message = "";
-    $this->token = NULL;
-  }
-
-  public function loadView() {
-    parent::loadView();
-
-    if (isset($_GET["token"]) && is_string($_GET["token"]) && !empty($_GET["token"])) {
-      $this->token = $_GET["token"];
-      $req = new \Api\User\CheckToken($this->getDocument()->getUser());
-      $this->success = $req->execute(array("token" => $_GET["token"]));
-      if ($this->success) {
-        if (strcmp($req->getResult()["token"]["type"], "password_reset") !== 0) {
-          $this->success = false;
-          $this->message = "The given token has a wrong type.";
-        }
-      } else {
-        $this->message = "Error requesting password reset: " . $req->getLastError();
-      }
-    }
-  }
-
-  protected function getAccountContent() {
-    if (!$this->success) {
-      $html = $this->createErrorText($this->message);
-      if ($this->token !== null) {
-        $html .= "<a href='/resetPassword' class='btn btn-primary'>Go back</a>";
-      }
-      return $html;
-    }
-
-    if ($this->token === null) {
-      return  "<p class='lead'>Enter your E-Mail address, to receive a password reset token.</p>
-          <form>
-        <div class=\"input-group\">
-          <div class=\"input-group-append\">
-            <span class=\"input-group-text\"><i class=\"fas fa-at\"></i></span>  
-          </div>
-          <input id=\"email\" autocomplete='email' name=\"email\" placeholder=\"E-Mail address\" class=\"form-control\" type=\"email\" maxlength=\"64\" />
-        </div>
-        <div class=\"input-group mt-2\" style='position: absolute;bottom: 15px'>
-          <button id='btnRequestPasswordReset' class='btn btn-primary'>
-            Request
-          </button>
-          <a href='/login' style='margin-left: 10px'>
-            <button class='btn btn-secondary' type='button'>
-              Back to Login
-            </button>
-          </a>
-        </div>
-      ";
-    } else {
-      return "<h4 class=\"pb-4\">Choose a new password</h4>
-      <form>
-        <input name='token' id='token' type='hidden' value='$this->token'/>
-        <div class=\"input-group mt-3\">
-          <div class=\"input-group-append\">
-            <span class=\"input-group-text\"><i class=\"fas fa-key\"></i></span>
-          </div>
-          <input type=\"password\" autocomplete='new-password' name='password' id='password' class=\"form-control\" placeholder=\"Password\">
-        </div>
-        <div class=\"input-group mt-3\">
-          <div class=\"input-group-append\">
-            <span class=\"input-group-text\"><i class=\"fas fa-key\"></i></span>
-          </div>
-          <input type=\"password\" autocomplete='new-password' name='confirmPassword' id='confirmPassword' class=\"form-control\" placeholder=\"Confirm Password\">
-        </div>
-        <div class=\"input-group mt-3\">
-          <button type=\"button\" class=\"btn btn-success\" id='btnResetPassword'>Submit</button>
-        </div>
-     </form>";
-    }
-  }
-}

+ 0 - 20
core/Views/Admin/AdminDashboardBody.class.php

@@ -1,20 +0,0 @@
-<?php
-
-namespace Views\Admin;
-
-use Elements\Body;
-use Elements\Script;
-
-class AdminDashboardBody extends Body {
-
-  public function __construct($document) {
-    parent::__construct($document);
-  }
-
-  public function getCode(): string {
-    $html = parent::getCode();
-    $script = new Script(Script::MIME_TEXT_JAVASCRIPT, "/js/admin.min.js");
-    $html .= "<body><div class=\"wrapper\" id=\"root\">$script</div></body>";
-    return $html;
-  }
-}

+ 0 - 72
core/Views/Admin/LoginBody.class.php

@@ -1,72 +0,0 @@
-<?php
-
-namespace Views\Admin;
-
-use Elements\Body;
-use Elements\Link;
-use Elements\Script;
-use Views\LanguageFlags;
-
-class LoginBody extends Body {
-
-  public function __construct($document) {
-    parent::__construct($document);
-  }
-
-  public function loadView() {
-    parent::loadView();
-    $head = $this->getDocument()->getHead();
-    $head->loadJQuery();
-    $head->loadBootstrap();
-    $head->addJS(Script::CORE);
-    $head->addCSS(Link::CORE);
-    $head->addJS(Script::ACCOUNT);
-    $head->addCSS(Link::ACCOUNT);
-  }
-
-  public function getCode(): string {
-    $html = parent::getCode();
-
-    $username = L("Username");
-    $password = L("Password");
-    $login = L("Login");
-    $backToStartPage = L("Back to Start Page");
-    $stayLoggedIn = L("Stay logged in");
-
-    $flags = $this->load(LanguageFlags::class);
-    $iconBack = $this->createIcon("arrow-circle-left");
-    $domain = $_SERVER['HTTP_HOST'];
-    $protocol = getProtocol();
-
-    $html .= "
-      <body>
-        <div class=\"container mt-4\">
-          <div class=\"title text-center\">
-            <h2>Admin Control Panel</h2>
-          </div>
-          <div class=\"row\">
-             <div class=\"col-lg-6 col-12 m-auto\">
-            <form class=\"loginForm\">
-              <label for=\"username\">$username</label>
-              <input type=\"text\" class=\"form-control\" name=\"username\" id=\"username\" placeholder=\"$username\" required autofocus />
-              <label for=\"password\">$password</label>
-              <input type=\"password\" class=\"form-control\" name=\"password\" id=\"password\" placeholder=\"$password\" required />
-              <div class=\"form-check\">
-                <input type=\"checkbox\" class=\"form-check-input\" id=\"stayLoggedIn\" name=\"stayLoggedIn\">
-                <label class=\"form-check-label\" for=\"stayLoggedIn\">$stayLoggedIn</label>
-              </div>
-              <button class=\"btn btn-lg btn-primary btn-block\" id=\"btnLogin\" type=\"button\">$login</button>
-              <div class=\"alert alert-danger\" style='display:none' role=\"alert\" id=\"alertMessage\"></div>
-              <span class=\"flags position-absolute\">$flags</span>
-            </form>
-            <div class=\"p-1\">
-              <a href=\"$protocol://$domain\">$iconBack&nbsp;$backToStartPage</a>
-            </div>
-          </div>
-          </div>
-        </div>
-      </body>";
-
-    return $html;
-  }
-}

+ 0 - 13
core/Views/View404.class.php

@@ -1,13 +0,0 @@
-<?php
-
-namespace Views;
-
-use Elements\View;
-
-class View404 extends View {
-
-  public function getCode(): string {
-    return parent::getCode() . "<b>Not found</b>";
-  }
-
-};

+ 27 - 7
core/core.php

@@ -1,14 +1,18 @@
 <?php
 
-define("WEBBASE_VERSION", "1.3.0");
+require_once "External/vendor/autoload.php";
+
+define("WEBBASE_VERSION", "1.3.0-beta");
 
 spl_autoload_extensions(".php");
 spl_autoload_register(function($class) {
-  $full_path = WEBROOT . "/" . getClassPath($class);
-  if (file_exists($full_path)) {
-    include_once $full_path;
-  } else {
-    include_once getClassPath($class, false);
+  if (!class_exists($class)) {
+    $full_path = WEBROOT . "/" . getClassPath($class);
+    if (file_exists($full_path)) {
+      include_once $full_path;
+    } else {
+      include_once getClassPath($class, false);
+    }
   }
 });
 
@@ -24,6 +28,13 @@ function getProtocol(): string {
   return $isSecure ? 'https' : 'http';
 }
 
+function uuidv4(): string {
+  $data = random_bytes(16);
+  $data[6] = chr(ord($data[6]) & 0x0f | 0x40); // set version to 0100
+  $data[8] = chr(ord($data[8]) & 0x3f | 0x80); // set bits 6-7 to 10
+  return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
+}
+
 function generateRandomString($length, $type = "ascii"): string {
   $randomString = '';
 
@@ -31,12 +42,14 @@ function generateRandomString($length, $type = "ascii"): string {
   $uppercase = strtoupper($lowercase);
   $digits    = "0123456789";
   $hex       = $digits . substr($lowercase, 0, 6);
-  $ascii     = $lowercase . $uppercase . $digits;
+  $ascii     = $uppercase . $lowercase . $digits;
 
   if ($length > 0) {
     $type = strtolower($type);
     if ($type === "hex") {
       $charset = $hex;
+    } else if ($type === "base64") {
+      $charset = $ascii . "/+";
     } else {
       $charset = $ascii;
     }
@@ -136,6 +149,13 @@ function urlId($str) {
   return urlencode(htmlspecialchars(preg_replace("[: ]","-", $str)));
 }
 
+function html_attributes(array $attributes): string {
+  return implode(" ", array_map(function ($key) use ($attributes) {
+    $value = $attributes[$key];
+    return "$key=\"$value\"";
+  }, array_keys($attributes)));
+}
+
 function getClassPath($class, $suffix = true): string {
   $path = str_replace('\\', '/', $class);
   $path = array_values(array_filter(explode("/", $path)));

+ 46 - 41
docker/nginx/site.conf

@@ -1,46 +1,51 @@
 server {
-    index index.php index.html;
-    error_log  /var/log/nginx/error.log;
-    access_log /var/log/nginx/access.log;
-    root /application;
-
-    # rewrite api
-    rewrite ^/api(/.*)$ /index.php?api=$1;
-
-    # deny access to .gitignore / .htaccess
-    location ~ /\. {
-        rewrite ^(.*)$ /index.php?site=$1;
-    }
-
-    # deny access to docker-compose.yml
-    location /docker-compose.yml {
-        rewrite ^(.*)$ /index.php?site=$1;
-    }
-
-    # deny access to specific directories
-    location ~ ^/(files/uploaded|adminPanel|fileControlPanel|docker|core)/.*$ {
-        rewrite ^(.*)$ /index.php?site=$1;
+	index index.php index.html;
+	error_log  /var/log/nginx/error.log;
+	access_log /var/log/nginx/access.log;
+	root /application;
+
+	# rewrite api
+	rewrite ^/api(/.*)$ /index.php?api=$1;
+
+	# deny access to .gitignore / .htaccess
+	location ~ /\. {
+		rewrite ^(.*)$ /index.php?site=$1;
+	}
+
+	# deny access to docker-compose.yml
+	location /docker-compose.yml {
+		rewrite ^(.*)$ /index.php?site=$1;
+	}
+
+	# deny access to specific directories
+	location ~ ^/(files/uploaded|adminPanel|fileControlPanel|docker|core|test)/.*$ {
+		rewrite ^(.*)$ /index.php?site=$1;
+	}
+
+	# caching
+    location ~ ^/(static|js|css)/.*$ {
+        add_header "Cache-Control" "max-age=0; must-revalidate";
     }
 
-    #  try to find the specified file
-    location / {
-        try_files $uri $uri @redirectToIndex;
-    }
-
-    # redirect to index.php
-    location @redirectToIndex {
-        rewrite ^(.*)$ /index.php?site=$1;
-    }
-
-    # serve .php files
-    location ~ \.php$ {
-        try_files $uri =404;
-        fastcgi_split_path_info ^(.+\.php)(/.+)$;
-        fastcgi_pass php:9000;
-        fastcgi_index index.php;
-        include fastcgi_params;
-        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
-        fastcgi_param PATH_INFO $fastcgi_path_info;
-    }
+	#  try to find the specified file
+	location / {
+		try_files $uri $uri @redirectToIndex;
+	}
+
+	# redirect to index.php
+	location @redirectToIndex {
+		rewrite ^(.*)$ /index.php?site=$1;
+	}
+
+	# serve .php files
+	location ~ \.php$ {
+		try_files $uri =404;
+		fastcgi_split_path_info ^(.+\.php)(/.+)$;
+		fastcgi_pass php:9000;
+		fastcgi_index index.php;
+		include fastcgi_params;
+		fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
+		fastcgi_param PATH_INFO $fastcgi_path_info;
+	}
 
 }

+ 8 - 7
index.php

@@ -4,10 +4,11 @@ include_once 'core/core.php';
 include_once 'core/datetime.php';
 include_once 'core/constants.php';
 
-if (is_file("MAINTENANCE")) {
+define("WEBROOT", realpath("."));
+
+if (is_file("MAINTENANCE") && !in_array($_SERVER['REMOTE_ADDR'], ['127.0.0.1', '::1'])) {
   http_response_code(503);
-  $currentDir = dirname(__FILE__);
-  serveStatic($currentDir, "/static/maintenance.html");
+  serveStatic(WEBROOT, "/static/maintenance.html");
   die();
 }
 
@@ -34,10 +35,10 @@ if(isset($_GET["api"]) && is_string($_GET["api"])) {
   } else {
     $apiFunction = $_GET["api"];
     if(empty($apiFunction)) {
-      header("403 Forbidden");
+      http_response_code(403);
       $response = "";
     } else if(!preg_match("/[a-zA-Z]+(\/[a-zA-Z]+)*/", $apiFunction)) {
-      header("400 Bad Request");
+      http_response_code(400);
       $response = createError("Invalid Method");
     } else {
       $apiFunction = array_filter(array_map('ucfirst', explode("/", $apiFunction)));
@@ -52,13 +53,13 @@ if(isset($_GET["api"]) && is_string($_GET["api"])) {
       try {
         $file = getClassPath($parentClass);
         if(!file_exists($file) || !class_exists($parentClass) || !class_exists($apiClass)) {
-          header("404 Not Found");
+          http_response_code(404);
           $response = createError("Not found");
         } else {
           $parentClass = new ReflectionClass($parentClass);
           $apiClass = new ReflectionClass($apiClass);
           if(!$apiClass->isSubclassOf(Request::class) || !$apiClass->isInstantiable()) {
-            header("400 Bad Request");
+            http_response_code(400);
             $response = createError("Invalid Method");
           } else {
             $request = $apiClass->newInstanceArgs(array($user, true));

+ 3 - 1
js/account.js

@@ -123,8 +123,8 @@ $(document).ready(function () {
                 if (!res.success) {
                     showAlert("danger", res.msg);
                 } else {
-                    showAlert("success", "Account successfully created. You may now login.");
                     $("input").val("");
+                    document.location = "/login?success=" +  encodeURIComponent("Account successfully created. You may now login.");
                 }
             });
         }
@@ -182,6 +182,8 @@ $(document).ready(function () {
                 } else {
                     showAlert("success", "Your password was successfully changed. You may now login.");
                     $("input:not([id='siteKey'])").val("");
+                    btn.hide();
+                    $("#backToLogin").show();
                 }
             });
         }

+ 143 - 0
test/AesStream.test.php

@@ -0,0 +1,143 @@
+<?php
+
+use Objects\AesStream;
+
+class AesStreamTest extends PHPUnit\Framework\TestCase {
+
+  static string $TEMP_FILE;
+
+  public static function setUpBeforeClass(): void {
+    AesStreamTest::$TEMP_FILE = tempnam(sys_get_temp_dir(), 'aesTest');
+  }
+
+  public static function tearDownAfterClass(): void {
+    unlink(AesStreamTest::$TEMP_FILE);
+  }
+
+  public function testConstructorInvalidKey1() {
+    $this->expectExceptionMessage("Invalid Key Size");
+    $this->expectException(\Exception::class);
+    new AesStream("", "");
+  }
+
+  public function testConstructorInvalidKey2() {
+    $this->expectExceptionMessage("Invalid Key Size");
+    $this->expectException(\Exception::class);
+    new AesStream(str_repeat("A",15), "");
+  }
+
+  public function testConstructorInvalidKey3() {
+    $this->expectExceptionMessage("Invalid Key Size");
+    $this->expectException(\Exception::class);
+    new AesStream(str_repeat("A",33), "");
+  }
+
+  public function testConstructorInvalidIV1() {
+    $this->expectExceptionMessage("Invalid IV Size");
+    $this->expectException(\Exception::class);
+    new AesStream(str_repeat("A",32), str_repeat("B", 17));
+  }
+
+  public function testConstructorValid() {
+    $key = str_repeat("A",32);
+    $iv = str_repeat("B", 16);
+    $aesStream = new AesStream($key, $iv);
+    $this->assertInstanceOf(AesStream::class, $aesStream);
+    $this->assertEquals($key, $aesStream->getKey());
+    $this->assertEquals($iv, $aesStream->getIV());
+    $this->assertEquals("aes-256-ctr", $aesStream->getCipherMode());
+  }
+
+  private function getOutput(string $chunk, string &$data) {
+    $data .= $chunk;
+  }
+
+  public function testEncrypt() {
+    $key = str_repeat("A", 32);
+    $iv  = str_repeat("B", 16);
+    $aesStream = new AesStream($key, $iv);
+
+    $data = [
+      "43"   => "8c",   # small block test 1 (1 byte)
+      "abcd" => "6424", # small block test 2 (2 byte)
+      "a37c599429cfdefde6546ad6d7082a" => "6c9539264abc8cae39308dbc86e768",     # small block test 3 (15 byte)
+      "43b3504077482bd9bf8c3c08ad3c937f" => "8c5a30f2143b798a60e8db62fcd3d1f7", # one block (16 byte)
+      "9b241a3d7e9f03f6e66a8fa0cba3221008eda86f465e3fbfb0f3a4d3527cffb7"
+        => "54cd7a8f1dec51a5390e68ca9a4c60986aaafadd42b6960a09deedfa7f2cf1c3"   # two blocks (16 byte)
+    ];
+
+    foreach ($data as $pt => $ct) {
+      $output = "";
+      file_put_contents(AesStreamTest::$TEMP_FILE, hex2bin($pt));
+      $aesStream->setInputFile(AesStreamTest::$TEMP_FILE);
+      $aesStream->setOutput(function($chunk) use (&$output) { $this->getOutput($chunk, $output); });
+      $aesStream->start();
+      $this->assertEquals($ct, bin2hex($output), $ct . " != " . bin2hex($output));
+    }
+  }
+
+  private function openssl(AesStream $aesStream) {
+    // check if openssl util produce the same output
+    $cmd = ["/usr/bin/openssl", $aesStream->getCipherMode(), "-K", bin2hex($aesStream->getKey()), "-iv", bin2hex($aesStream->getIV()), "-in", AesStreamTest::$TEMP_FILE];
+    $proc = proc_open($cmd, [1 => ["pipe", "w"]], $pipes);
+    $this->assertTrue(is_resource($proc));
+    $this->assertTrue(is_resource($pipes[1]));
+    $output = stream_get_contents($pipes[1]);
+    proc_close($proc);
+    return $output;
+  }
+
+  private function testEncryptDecrypt($key, $iv, $inputData) {
+    $aesStream = new AesStream($key, $iv);
+    $inputSize = strlen($inputData);
+    file_put_contents(AesStreamTest::$TEMP_FILE, $inputData);
+
+    $output = "";
+    $aesStream->setInputFile(AesStreamTest::$TEMP_FILE);
+    $aesStream->setOutput(function($chunk) use (&$output) { $this->getOutput($chunk, $output); });
+    $aesStream->start();
+
+    $this->assertEquals($inputSize, strlen($output));
+    $this->assertNotEquals($inputData, $output);
+
+    // check if openssl util produce the same output
+    $this->assertEquals($this->openssl($aesStream), $output);
+
+    file_put_contents(AesStreamTest::$TEMP_FILE, $output);
+    $output = "";
+    $aesStream->setInputFile(AesStreamTest::$TEMP_FILE);
+    $aesStream->setOutput(function($chunk) use (&$output) { $this->getOutput($chunk, $output); });
+    $aesStream->start();
+    $this->assertEquals($inputData, $output);
+
+    // check if openssl util produce the same output
+    $this->assertEquals($this->openssl($aesStream), $output);
+  }
+
+  public function testEncryptDecryptRandom() {
+    $chunkSize = 65536;
+    $key = random_bytes(32);
+    $iv  = random_bytes(16);
+    $inputSize = 10 * $chunkSize;
+    $inputData = random_bytes($inputSize);
+    $this->testEncryptDecrypt($key, $iv, $inputData);
+  }
+
+  public function testEncryptDecryptLargeIV() {
+    $chunkSize = 65536;
+    $key = random_bytes(32);
+    $iv  = hex2bin(str_repeat("FF", 16));
+    $inputSize = 10 * $chunkSize;
+    $inputData = random_bytes($inputSize);
+    $this->testEncryptDecrypt($key, $iv, $inputData);
+  }
+
+  public function testEncryptDecryptZeroIV() {
+    $chunkSize = 65536;
+    $key = random_bytes(32);
+    $iv  = hex2bin(str_repeat("00", 16));
+    $inputSize = 10 * $chunkSize;
+    $inputData = random_bytes($inputSize);
+    $this->testEncryptDecrypt($key, $iv, $inputData);
+  }
+}

+ 108 - 0
test/Parameter.test.php

@@ -0,0 +1,108 @@
+<?php
+
+use Api\Parameter\ArrayType;
+use Api\Parameter\StringType;
+use Api\Parameter\Parameter;
+
+class ParameterTest extends \PHPUnit\Framework\TestCase {
+
+  public function testStringType() {
+
+    // test various string sizes
+    $unlimited = new StringType("test_unlimited");
+    $this->assertTrue($unlimited->parseParam(str_repeat("A", 1024)));
+
+    $empty     = new StringType("test_empty", 0);
+    $this->assertTrue($empty->parseParam(""));
+    $this->assertTrue($empty->parseParam("A"));
+
+    $one       = new StringType("test_one", 1);
+    $this->assertTrue($one->parseParam(""));
+    $this->assertTrue($one->parseParam("A"));
+    $this->assertFalse($one->parseParam("AB"));
+
+    $randomSize = rand(1, 64);
+    $random    = new StringType("test_empty", $randomSize);
+    $data      = str_repeat("A", $randomSize);
+    $this->assertTrue($random->parseParam(""));
+    $this->assertTrue($random->parseParam("A"));
+    $this->assertTrue($random->parseParam($data));
+    $this->assertEquals($data, $random->value);
+
+    // test data types
+    $this->assertFalse($random->parseParam(null));
+    $this->assertFalse($random->parseParam(1));
+    $this->assertFalse($random->parseParam(2.5));
+    $this->assertFalse($random->parseParam(true));
+    $this->assertFalse($random->parseParam(false));
+    $this->assertFalse($random->parseParam(["key" => 1]));
+  }
+
+  public function testArrayType() {
+
+    // int array type
+    $arrayType = new ArrayType("int_array", Parameter::TYPE_INT);
+    $this->assertTrue($arrayType->parseParam([1,2,3]));
+    $this->assertTrue($arrayType->parseParam([1]));
+    $this->assertTrue($arrayType->parseParam(["1"]));
+    $this->assertTrue($arrayType->parseParam([1.0]));
+    $this->assertTrue($arrayType->parseParam([]));
+    $this->assertTrue($arrayType->parseParam(["1.0"]));
+    $this->assertFalse($arrayType->parseParam([1.2]));
+    $this->assertFalse($arrayType->parseParam(["1.5"]));
+    $this->assertFalse($arrayType->parseParam([true]));
+    $this->assertFalse($arrayType->parseParam(1));
+
+    // optional single value
+    $arrayType = new ArrayType("int_array_single", Parameter::TYPE_INT, true);
+    $this->assertTrue($arrayType->parseParam(1));
+
+    // mixed values
+    $arrayType = new ArrayType("mixed_array", Parameter::TYPE_MIXED);
+    $this->assertTrue($arrayType->parseParam([1, 2.5, "test", false]));
+  }
+
+  public function testParseType() {
+    // int
+    $this->assertEquals(Parameter::TYPE_INT, Parameter::parseType(1));
+    $this->assertEquals(Parameter::TYPE_INT, Parameter::parseType(1.0));
+    $this->assertEquals(Parameter::TYPE_INT, Parameter::parseType("1"));
+    $this->assertEquals(Parameter::TYPE_INT, Parameter::parseType("1.0"));
+
+    // array
+    $this->assertEquals(Parameter::TYPE_ARRAY, Parameter::parseType([1, true]));
+
+    // float
+    $this->assertEquals(Parameter::TYPE_FLOAT, Parameter::parseType(1.5));
+    $this->assertEquals(Parameter::TYPE_FLOAT, Parameter::parseType(1.234e2));
+    $this->assertEquals(Parameter::TYPE_FLOAT, Parameter::parseType("1.75"));
+
+    // boolean
+    $this->assertEquals(Parameter::TYPE_BOOLEAN, Parameter::parseType(true));
+    $this->assertEquals(Parameter::TYPE_BOOLEAN, Parameter::parseType(false));
+    $this->assertEquals(Parameter::TYPE_BOOLEAN, Parameter::parseType("true"));
+    $this->assertEquals(Parameter::TYPE_BOOLEAN, Parameter::parseType("false"));
+
+    // date
+    $this->assertEquals(Parameter::TYPE_DATE, Parameter::parseType("2021-11-13"));
+    $this->assertEquals(Parameter::TYPE_STRING, Parameter::parseType("2021-13-11")); # invalid date
+
+    // time
+    $this->assertEquals(Parameter::TYPE_TIME, Parameter::parseType("10:11:12"));
+    $this->assertEquals(Parameter::TYPE_STRING, Parameter::parseType("25:11:12")); # invalid time
+
+    // datetime
+    $this->assertEquals(Parameter::TYPE_DATE_TIME, Parameter::parseType("2021-11-13 10:11:12"));
+    $this->assertEquals(Parameter::TYPE_STRING, Parameter::parseType("2021-13-13 10:11:12")); # invalid date
+    $this->assertEquals(Parameter::TYPE_STRING, Parameter::parseType("2021-13-11 10:61:12")); # invalid time
+
+    // email
+    $this->assertEquals(Parameter::TYPE_EMAIL, Parameter::parseType("a@b.com"));
+    $this->assertEquals(Parameter::TYPE_EMAIL, Parameter::parseType("test.123@example.com"));
+    $this->assertEquals(Parameter::TYPE_STRING, Parameter::parseType("@example.com")); # invalid email
+    $this->assertEquals(Parameter::TYPE_STRING, Parameter::parseType("test@")); # invalid email
+
+    // string, everything else
+    $this->assertEquals(Parameter::TYPE_STRING, Parameter::parseType("test"));
+  }
+}

+ 192 - 0
test/Request.test.php

@@ -0,0 +1,192 @@
+<?php
+
+use Api\Request;
+use Configuration\Configuration;
+use Objects\User;
+
+function __new_header_impl(string $line) {
+  if (preg_match("/^HTTP\/([0-9.]+) (\d+) (.*)$/", $line, $m)) {
+    RequestTest::$SENT_STATUS_CODE = intval($m[2]);
+    return;
+  }
+
+  $key = $line;
+  $value = "";
+  $index = strpos($key, ": ");
+  if ($index !== false) {
+    $key = substr($line, 0, $index);
+    $value = substr($line, $index + 2);
+  }
+
+  RequestTest::$SENT_HEADERS[$key] = $value;
+}
+
+function __new_http_response_code_impl(int $code) {
+  RequestTest::$SENT_STATUS_CODE = $code;
+}
+
+function __new_die_impl($content) {
+  RequestTest::$SENT_CONTENT = $content;
+}
+
+class RequestTest extends \PHPUnit\Framework\TestCase {
+
+  const FUNCTION_OVERRIDES = ["header", "http_response_code"];
+  static User $USER;
+  static User $USER_LOGGED_IN;
+
+  static ?string $SENT_CONTENT;
+  static array $SENT_HEADERS;
+  static ?int $SENT_STATUS_CODE;
+
+  public static function setUpBeforeClass(): void {
+
+    $config = new Configuration();
+    RequestTest::$USER = new User($config);
+    RequestTest::$USER_LOGGED_IN = new User($config);
+    if (!RequestTest::$USER->getSQL() || !RequestTest::$USER->getSQL()->isConnected()) {
+      throw new Exception("Could not establish database connection");
+    } else {
+      RequestTest::$USER->setLanguage(\Objects\Language::DEFAULT_LANGUAGE());
+    }
+
+    if (!function_exists("runkit7_function_rename") || !function_exists("runkit7_function_remove")) {
+      throw new Exception("Request Unit Test requires runkit7 extension");
+    }
+
+    if (ini_get("runkit.internal_override") !== "1") {
+      throw new Exception("Request Unit Test requires runkit7 with internal_override enabled to function properly");
+    }
+
+    foreach (self::FUNCTION_OVERRIDES as $functionName) {
+      runkit7_function_rename($functionName, "__orig_${functionName}_impl");
+      runkit7_function_rename("__new_${functionName}_impl", $functionName);
+    }
+  }
+
+  public static function tearDownAfterClass(): void {
+    RequestTest::$USER->getSQL()->close();
+    foreach (self::FUNCTION_OVERRIDES as $functionName) {
+      runkit7_function_remove($functionName);
+      runkit7_function_rename("__orig_${functionName}_impl", $functionName);
+    }
+  }
+
+  private function simulateRequest(Request $request, string $method, array $get = [], array $post = [], array $headers = []): bool {
+
+    if (!is_cli()) {
+      self::throwException(new \Exception("Cannot simulate request outside cli"));
+    }
+
+    $_SERVER = [];
+    $_SERVER["REQUEST_METHOD"] = $method;
+    self::$SENT_HEADERS = [];
+    self::$SENT_STATUS_CODE = null;
+    self::$SENT_CONTENT = null;
+
+    foreach ($headers as $key => $value) {
+      $key = "HTTP_" . preg_replace("/\s/", "_", strtoupper($key));
+      $_SERVER[$key] = $value;
+    }
+
+    $_GET = $get;
+    $_POST = $post;
+
+    return $request->execute();
+  }
+
+  public function testAllMethods() {
+    // all methods allowed
+    $allMethodsAllowed = new RequestAllMethods(RequestTest::$USER, true);
+    $this->assertTrue($this->simulateRequest($allMethodsAllowed, "GET"), $allMethodsAllowed->getLastError());
+    $this->assertTrue($this->simulateRequest($allMethodsAllowed, "POST"), $allMethodsAllowed->getLastError());
+    $this->assertFalse($this->simulateRequest($allMethodsAllowed, "PUT"), $allMethodsAllowed->getLastError());
+    $this->assertFalse($this->simulateRequest($allMethodsAllowed, "DELETE"), $allMethodsAllowed->getLastError());
+    $this->assertTrue($this->simulateRequest($allMethodsAllowed, "OPTIONS"), $allMethodsAllowed->getLastError());
+    $this->assertEquals(204, self::$SENT_STATUS_CODE);
+    $this->assertEquals(["Allow" => "OPTIONS, GET, POST"], self::$SENT_HEADERS);
+  }
+
+  public function testOnlyPost() {
+    // only post allowed
+    $onlyPostAllowed = new RequestOnlyPost(RequestTest::$USER, true);
+    $this->assertFalse($this->simulateRequest($onlyPostAllowed, "GET"));
+    $this->assertEquals("This method is not allowed", $onlyPostAllowed->getLastError(), $onlyPostAllowed->getLastError());
+    $this->assertEquals(405, self::$SENT_STATUS_CODE);
+    $this->assertTrue($this->simulateRequest($onlyPostAllowed, "POST"), $onlyPostAllowed->getLastError());
+    $this->assertTrue($this->simulateRequest($onlyPostAllowed, "OPTIONS"), $onlyPostAllowed->getLastError());
+    $this->assertEquals(204, self::$SENT_STATUS_CODE);
+    $this->assertEquals(["Allow" => "OPTIONS, POST"], self::$SENT_HEADERS);
+  }
+
+  public function testPrivate() {
+    // private method
+    $privateExternal = new RequestPrivate(RequestTest::$USER, true);
+    $this->assertFalse($this->simulateRequest($privateExternal, "GET"));
+    $this->assertEquals("This function is private.", $privateExternal->getLastError());
+    $this->assertEquals(403, self::$SENT_STATUS_CODE);
+
+    $privateInternal = new RequestPrivate(RequestTest::$USER, false);
+    $this->assertTrue($privateInternal->execute());
+  }
+
+  public function testDisabled() {
+    // disabled method
+    $disabledMethod = new RequestDisabled(RequestTest::$USER, true);
+    $this->assertFalse($this->simulateRequest($disabledMethod, "GET"));
+    $this->assertEquals("This function is currently disabled.", $disabledMethod->getLastError(), $disabledMethod->getLastError());
+    $this->assertEquals(503, self::$SENT_STATUS_CODE);
+  }
+
+  public function testLoginRequired() {
+    $loginRequired = new RequestLoginRequired(RequestTest::$USER, true);
+    $this->assertFalse($this->simulateRequest($loginRequired, "GET"));
+    $this->assertEquals("You are not logged in.", $loginRequired->getLastError(), $loginRequired->getLastError());
+    $this->assertEquals(401, self::$SENT_STATUS_CODE);
+  }
+}
+
+abstract class TestRequest extends Request {
+  public function __construct(User $user, bool $externalCall = false, $params = []) {
+    parent::__construct($user, $externalCall, $params);
+  }
+
+  protected function _die(string $data = ""): bool {
+    __new_die_impl($data);
+    return true;
+  }
+}
+
+class RequestAllMethods extends TestRequest {
+  public function __construct(User $user, bool $externalCall = false) {
+    parent::__construct($user, $externalCall, []);
+  }
+}
+
+class RequestOnlyPost extends TestRequest {
+  public function __construct(User $user, bool $externalCall = false) {
+    parent::__construct($user, $externalCall, []);
+    $this->forbidMethod("GET");
+  }
+}
+
+class RequestPrivate extends TestRequest {
+  public function __construct(User $user, bool $externalCall = false) {
+    parent::__construct($user, $externalCall, []);
+    $this->isPublic = false;
+  }
+}
+
+class RequestDisabled extends TestRequest {
+  public function __construct(User $user, bool $externalCall = false) {
+    parent::__construct($user, $externalCall, []);
+    $this->isDisabled = true;
+  }
+}
+
+class RequestLoginRequired extends TestRequest {
+  public function __construct(User $user, bool $externalCall = false) {
+    parent::__construct($user, $externalCall, []);
+    $this->loginRequired = true;
+  }
+}

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