Roman 2 년 전
부모
커밋
25d47f7528

+ 1 - 0
.htaccess

@@ -3,6 +3,7 @@ Options -Indexes
 
 DirectorySlash Off
 
+SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1
 RewriteEngine On
 RewriteRule ^api(/.*)?$ /index.php?api=$1 [L,QSA]
 

+ 2 - 0
cli.php

@@ -1,5 +1,7 @@
 <?php
 
+define('WEBROOT', realpath("."));
+
 include_once 'core/core.php';
 require_once 'core/datetime.php';
 include_once 'core/constants.php';

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

@@ -6,7 +6,7 @@ namespace Api {
 
   abstract class ApiKeyAPI extends Request {
 
-    protected function apiKeyExists($id) {
+    protected function apiKeyExists($id): bool {
       $sql = $this->user->getSQL();
       $res = $sql->select($sql->count())
         ->from("ApiKey")

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

@@ -3,7 +3,7 @@
 namespace Api {
 
   abstract class PermissionAPI extends Request {
-    protected function checkStaticPermission() {
+    protected function checkStaticPermission(): bool {
       if (!$this->user->isLoggedIn() || !$this->user->hasGroup(USER_GROUP_ADMIN)) {
         return $this->createError("Permission denied.");
       }
@@ -21,6 +21,7 @@ namespace Api\Permission {
   use Driver\SQL\Column\Column;
   use Driver\SQL\Condition\Compare;
   use Driver\SQL\Condition\CondIn;
+  use Driver\SQL\Condition\CondLike;
   use Driver\SQL\Condition\CondNot;
   use Driver\SQL\Strategy\UpdateStrategy;
   use Objects\User;
@@ -44,14 +45,14 @@ namespace Api\Permission {
       $sql = $this->user->getSQL();
       $res = $sql->select("groups")
         ->from("ApiPermission")
-        ->where(new Compare("method", $method))
+        ->where(new CondLike($method, new Column("method")))
         ->execute();
 
       $this->success = ($res !== FALSE);
       $this->lastError = $sql->getLastError();
 
       if ($this->success) {
-        if (empty($res)) {
+        if (empty($res) || !is_array($res)) {
           return true;
         }
 

+ 31 - 8
core/Api/Request.class.php

@@ -45,9 +45,13 @@ class Request {
     }
   }
 
-  public function parseParams($values): bool {
+  public function parseParams($values, $structure = NULL): bool {
 
-    foreach ($this->params as $name => $param) {
+    if ($structure === NULL) {
+      $structure = $this->params;
+    }
+
+    foreach ($structure as $name => $param) {
       $value = $values[$name] ?? NULL;
 
       $isEmpty = (is_string($value) && strlen($value) === 0) || (is_array($value) && empty($value));
@@ -90,7 +94,7 @@ class Request {
       $values = $_REQUEST;
       if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_SERVER["CONTENT_TYPE"]) && in_array("application/json", explode(";", $_SERVER["CONTENT_TYPE"]))) {
         $jsonData = json_decode(file_get_contents('php://input'), true);
-        if ($jsonData) {
+        if ($jsonData !== null) {
           $values = array_merge($values, $jsonData);
         } else {
           $this->lastError = 'Invalid request body.';
@@ -124,9 +128,12 @@ class Request {
 
       // Logged in or api key authorized?
       if ($this->loginRequired) {
-        if (isset($values['api_key']) && $this->apiKeyAllowed) {
-          $apiKey = $values['api_key'];
-          $apiKeyAuthorized = $this->user->authorize($apiKey);
+        if (isset($_SERVER["HTTP_AUTHORIZATION"]) && $this->apiKeyAllowed) {
+          $authHeader = $_SERVER["HTTP_AUTHORIZATION"];
+          if (startsWith($authHeader, "Bearer ")) {
+            $apiKey = substr($authHeader, strlen("Bearer "));
+            $apiKeyAuthorized = $this->user->authorize($apiKey);
+          }
         }
 
         if (!$this->user->isLoggedIn() && !$apiKeyAuthorized) {
@@ -182,9 +189,13 @@ class Request {
     return false;
   }
 
-  protected function getParam($name) {
+  protected function getParam($name, $obj = NULL) {
     // i don't know why phpstorm
-    return (isset($this->params[$name]) ? $this->params[$name]->value : NULL);
+    if ($obj === NULL) {
+      $obj = $this->params;
+    }
+
+    return (isset($obj[$name]) ? $obj[$name]->value : NULL);
   }
 
   public function isPublic(): bool {
@@ -222,4 +233,16 @@ class Request {
     $this->result['msg'] = $this->lastError;
     return json_encode($this->result);
   }
+
+  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 ++ ) {
+      ob_end_flush();
+    }
+    flush();
+  }
 }

+ 1 - 0
core/Api/RoutesAPI.class.php

@@ -138,6 +138,7 @@ namespace Api\Routes {
         ->from("Route")
         ->where(new CondBool("active"))
         ->where(new CondRegex($request, new Column("request")))
+        ->orderBy("uid")->ascending()
         ->limit(1)
         ->execute();
 

+ 195 - 37
core/Api/UserAPI.class.php

@@ -6,7 +6,7 @@ namespace Api {
 
   abstract class UserAPI extends Request {
 
-    protected function userExists(?string $username, ?string $email) {
+    protected function userExists(?string $username, ?string $email = null) {
 
       $conditions = array();
       if ($username) {
@@ -52,12 +52,19 @@ namespace Api {
       return true;
     }
 
-    protected function checkRequirements($username, $password, $confirmPassword) {
-      if(strlen($username) < 5 || strlen($username) > 32) {
+    protected function checkUsernameRequirements($username): bool {
+      if (strlen($username) < 5 || strlen($username) > 32) {
         return $this->createError("The username should be between 5 and 32 characters long");
+      } else if (!preg_match("/[a-zA-Z0-9_\-]+/", $username)) {
+        return $this->createError("The username should only contain the following characters: a-z A-Z 0-9 _ -");
       }
 
-      return $this->checkPasswordRequirements($password, $confirmPassword);
+      return true;
+    }
+
+    protected function checkRequirements($username, $password, $confirmPassword): bool {
+      return $this->checkUsernameRequirements($username) &&
+        $this->checkPasswordRequirements($password, $confirmPassword);
     }
 
     protected function insertUser($username, $email, $password, $confirmed) {
@@ -123,6 +130,18 @@ namespace Api {
         ->where(new Compare("token", $token))
         ->execute();
     }
+
+    protected function insertToken(int $userId, string $token, string $tokenType, int $duration): bool {
+      $validUntil = (new \DateTime())->modify("+$duration hour");
+      $sql = $this->user->getSQL();
+      $res = $sql->insert("UserToken", array("user_id", "token", "token_type", "valid_until"))
+        ->addRow($userId, $token, $tokenType, $validUntil)
+        ->execute();
+
+      $this->success = ($res !== FALSE);
+      $this->lastError = $sql->getLastError();
+      return $this->success;
+    }
   }
 
 }
@@ -193,7 +212,7 @@ namespace Api\User {
       ));
     }
 
-    private function getUserCount() {
+    private function getUserCount(): bool {
 
       $sql = $this->user->getSQL();
       $res = $sql->select($sql->count())->from("User")->execute();
@@ -359,6 +378,23 @@ namespace Api\User {
         $this->result["loggedIn"] = false;
       } else {
         $this->result["loggedIn"] = true;
+        $userGroups = array_keys($this->user->getGroups());
+        $sql = $this->user->getSQL();
+        $res = $sql->select("method", "groups")
+          ->from("ApiPermission")
+          ->execute();
+
+        $permissions = [];
+        if (is_array($res)) {
+          foreach ($res as $row) {
+            $requiredGroups = json_decode($row["groups"], true);
+            if (empty($requiredGroups) || !empty(array_intersect($requiredGroups, $userGroups))) {
+              $permissions[] = $row["method"];
+            }
+          }
+        }
+
+        $this->result["permissions"] = $permissions;
       }
 
       $this->result["user"] = $this->user->jsonSerialize();
@@ -456,7 +492,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))
@@ -500,7 +536,6 @@ namespace Api\User {
       } else if (!$this->updateUser($result["user"]["uid"], $password)) {
         return false;
       } else {
-
         // Invalidate token
         $this->user->getSQL()
           ->update("UserToken")
@@ -519,9 +554,10 @@ namespace Api\User {
       parent::__construct($user, $externalCall, array(
         'token' => new StringType('token', 36)
       ));
+      $this->csrfTokenRequired = false;
     }
 
-    private function updateUser($uid) {
+    private function updateUser($uid): bool {
       $sql = $this->user->getSQL();
       $res = $sql->update("User")
         ->set("confirmed", true)
@@ -543,7 +579,6 @@ namespace Api\User {
       }
 
       $token = $this->getParam("token");
-
       $req = new CheckToken($this->user);
       $this->success = $req->execute(array("token" => $token));
       $this->lastError = $req->getLastError();
@@ -579,7 +614,7 @@ namespace Api\User {
       $this->forbidMethod("GET");
     }
 
-    private function wrongCredentials() {
+    private function wrongCredentials(): bool {
       $runtime = microtime(true) - $this->startedAt;
       $sleepTime = round(3e6 - $runtime);
       if ($sleepTime > 0) usleep($sleepTime);
@@ -613,7 +648,7 @@ namespace Api\User {
       $this->lastError = $sql->getLastError();
 
       if ($this->success) {
-        if (count($res) === 0) {
+        if (!is_array($res) || count($res) === 0) {
           return $this->wrongCredentials();
         } else {
           $row = $res[0];
@@ -621,6 +656,7 @@ namespace Api\User {
           $confirmed = $sql->parseBool($row["confirmed"]);
           if (password_verify($password, $row['password'])) {
             if (!$confirmed) {
+              $this->result["emailConfirmed"] = false;
               return $this->createError("Your email address has not been confirmed yet.");
             } else if (!($this->success = $this->user->createSession($uid, $stayLoggedIn))) {
               return $this->createError("Error creating Session: " . $sql->getLastError());
@@ -681,18 +717,6 @@ namespace Api\User {
       $this->csrfTokenRequired = false;
     }
 
-    private function insertToken() {
-      $validUntil = (new DateTime())->modify("+48 hour");
-      $sql = $this->user->getSQL();
-      $res = $sql->insert("UserToken", array("user_id", "token", "token_type", "valid_until"))
-        ->addRow($this->userId, $this->token, "email_confirm", $validUntil)
-        ->execute();
-
-      $this->success = ($res !== FALSE);
-      $this->lastError = $sql->getLastError();
-      return $this->success;
-    }
-
     public function execute($values = array()): bool {
       if (!parent::execute($values)) {
         return false;
@@ -720,6 +744,7 @@ namespace Api\User {
       $email = $this->getParam('email');
       $password = $this->getParam("password");
       $confirmPassword = $this->getParam("confirmPassword");
+
       if (!$this->userExists($username, $email)) {
         return false;
       }
@@ -733,14 +758,13 @@ namespace Api\User {
         return false;
       }
 
-      $id = $this->insertUser($username, $email, $password, false);
-      if ($id === FALSE) {
+      $this->userId = $this->insertUser($username, $email, $password, false);
+      if (!$this->success) {
         return false;
       }
 
-      $this->userId = $id;
       $this->token = generateRandomString(36);
-      if ($this->insertToken()) {
+      if ($this->insertToken($this->userId, $this->token, "email_confirm", 48)) {
         $settings = $this->user->getConfiguration()->getSettings();
         $baseUrl = htmlspecialchars($settings->getBaseUrl());
         $siteName = htmlspecialchars($settings->getSiteName());
@@ -845,6 +869,7 @@ namespace Api\User {
       ));
 
       $this->loginRequired = true;
+      $this->forbidMethod("GET");
     }
 
     public function execute($values = array()): bool {
@@ -887,8 +912,8 @@ namespace Api\User {
         }
 
         // Check for duplicate username, email
-        $usernameChanged = !is_null($username) ? strcasecmp($username, $user[0]["name"]) !== 0 : false;
-        $emailChanged = !is_null($email) ? strcasecmp($email, $user[0]["email"]) !== 0 : false;
+        $usernameChanged = !is_null($username) && strcasecmp($username, $user[0]["name"]) !== 0;
+        $emailChanged = !is_null($email) && strcasecmp($email, $user[0]["email"]) !== 0;
         if($usernameChanged || $emailChanged) {
           if (!$this->userExists($usernameChanged ? $username : NULL, $emailChanged ? $email : NULL)) {
             return false;
@@ -917,7 +942,7 @@ namespace Api\User {
           $this->success = ($res !== FALSE);
         }
 
-        if ($this->success && !empty($groupIds)) {
+        if ($this->success) {
 
           $deleteQuery = $sql->delete("UserGroup")->where(new Compare("user_id", $id));
           $insertQuery = $sql->insert("UserGroup", array("user_id", "group_id"));
@@ -926,7 +951,7 @@ namespace Api\User {
             $insertQuery->addRow($id, $groupId);
           }
 
-          $this->success = ($deleteQuery->execute() !== FALSE) && ($insertQuery->execute() !== FALSE);
+          $this->success = ($deleteQuery->execute() !== FALSE) && (empty($groupIds) || $insertQuery->execute() !== FALSE);
           $this->lastError = $sql->getLastError();
         }
       }
@@ -983,7 +1008,6 @@ namespace Api\User {
       }
 
       parent::__construct($user, $externalCall, $parameters);
-      $this->csrfTokenRequired = false;
     }
 
     public function execute($values = array()): bool {
@@ -1017,7 +1041,7 @@ namespace Api\User {
 
       if ($user !== null) {
         $token = generateRandomString(36);
-        if (!$this->insertToken($user["uid"], $token)) {
+        if (!$this->insertToken($user["uid"], $token, "password_reset", 1)) {
           return false;
         }
 
@@ -1067,16 +1091,102 @@ namespace Api\User {
 
       return $this->success;
     }
+  }
+
+  class ResendConfirmEmail extends UserAPI {
+    public function __construct(User $user, $externalCall = false) {
+      $parameters = array(
+        'email' => new Parameter('email', Parameter::TYPE_EMAIL),
+      );
+
+      $settings = $user->getConfiguration()->getSettings();
+      if ($settings->isRecaptchaEnabled()) {
+        $parameters["captcha"] = new StringType("captcha");
+      }
+
+      parent::__construct($user, $externalCall, $parameters);
+    }
+
+    public function execute($values = array()): bool {
+      if (!parent::execute($values)) {
+        return false;
+      }
+
+      if ($this->user->isLoggedIn()) {
+        return $this->createError("You already logged in.");
+      }
+
+      $settings = $this->user->getConfiguration()->getSettings();
+      if ($settings->isRecaptchaEnabled()) {
+        $captcha = $this->getParam("captcha");
+        $req = new VerifyCaptcha($this->user);
+        if (!$req->execute(array("captcha" => $captcha, "action" => "resendConfirmation"))) {
+          return $this->createError($req->getLastError());
+        }
+      }
+
+      $messageBody = $this->getMessageTemplate("message_confirm_email");
+      if ($messageBody === false) {
+        return false;
+      }
 
-    private function insertToken(int $id, string $token) {
-      $validUntil = (new DateTime())->modify("+1 hour");
+      $email = $this->getParam("email");
       $sql = $this->user->getSQL();
-      $res = $sql->insert("UserToken", array("user_id", "token", "token_type", "valid_until"))
-        ->addRow($id, $token, "password_reset", $validUntil)
+      $res = $sql->select("User.uid", "User.name", "UserToken.token", "UserToken.token_type", "UserToken.used")
+        ->from("User")
+        ->leftJoin("UserToken", "User.uid", "UserToken.user_id")
+        ->where(new Compare("User.email", $email))
+        ->where(new Compare("User.confirmed", false))
         ->execute();
 
       $this->success = ($res !== FALSE);
       $this->lastError = $sql->getLastError();
+      if (!$this->success) {
+        return $this->createError($sql->getLastError());
+      } else if (!is_array($res) || empty($res)) {
+        // user does not exist
+        return true;
+      }
+
+      $userId = $res[0]["uid"];
+      $token = current(
+        array_map(function ($row) {
+          return $row["token"];
+        }, array_filter($res, function ($row) use ($sql) {
+          return !$sql->parseBool($row["used"]) && $row["token_type"] === "email_confirm";
+        }))
+      );
+
+      if (!$token) {
+        // no token generated yet, let's generate one
+        $token = generateRandomString(36);
+        if (!$this->insertToken($userId, $token, "email_confirm", 48)) {
+          return false;
+        }
+      }
+
+      $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)
+      );
+
+      foreach($replacements as $key => $value) {
+        $messageBody = str_replace("{{{$key}}}", $value, $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();
       return $this->success;
     }
   }
@@ -1138,4 +1248,52 @@ namespace Api\User {
       }
     }
   }
+
+  class UpdateProfile extends UserAPI {
+
+    public function __construct(User $user, bool $externalCall = false) {
+      parent::__construct($user, $externalCall, array(
+        'username' => new StringType('username', 32, true, NULL),
+        'password' => new StringType('password', -1, true, NULL),
+      ));
+      $this->loginRequired = true;
+      $this->csrfTokenRequired = true;
+      $this->forbidMethod("GET");
+    }
+
+    public function execute($values = array()): bool {
+      if (!parent::execute($values)) {
+        return false;
+      }
+
+      $newUsername = $this->getParam("username");
+      $newPassword = $this->getParam("password");
+
+      if ($newUsername === null && $newPassword === null) {
+        return $this->createError("You must either provide an updated username or password");
+      }
+
+      $sql = $this->user->getSQL();
+      $query = $sql->update("User")->where(new Compare("id", $this->user->getId()));
+      if ($newUsername !== null) {
+        if (!$this->checkUsernameRequirements($newUsername) || $this->userExists($newUsername)) {
+          return false;
+        } else {
+          $query->set("name", $newUsername);
+        }
+      }
+
+      if ($newPassword !== null) { // TODO: confirm password?
+        if (!$this->checkPasswordRequirements($newPassword, $newPassword)) {
+          return false;
+        } else {
+          $query->set("password", $this->hashPassword($newPassword));
+        }
+      }
+
+      $this->success = $query->execute();
+      $this->lastError = $sql->getLastError();
+      return $this->success;
+    }
+  }
 }

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

@@ -47,9 +47,9 @@ class VerifyCaptcha extends Request {
     $this->success = false;
     $this->lastError = "Could not verify captcha: No response from google received.";
 
-    if($response) {
+    if ($response) {
       $this->success = $response["success"];
-      if(!$this->success) {
+      if (!$this->success) {
         $this->lastError = "Could not verify captcha: " . implode(";", $response["error-codes"]);
       } else {
         $score = $response["score"];

+ 5 - 4
core/Configuration/CreateDatabase.class.php

@@ -131,10 +131,11 @@ class CreateDatabase extends DatabaseScript {
 
     $queries[] = $sql->insert("Route", array("request", "action", "target", "extra"))
       ->addRow("^/admin(/.*)?$", "dynamic", "\\Documents\\Admin", NULL)
-      ->addRow("^/register(/)?$", "dynamic", "\\Documents\\Account", "\\Views\\Account\\Register")
-      ->addRow("^/confirmEmail(/)?$", "dynamic", "\\Documents\\Account", "\\Views\\Account\\ConfirmEmail")
-      ->addRow("^/acceptInvite(/)?$", "dynamic", "\\Documents\\Account", "\\Views\\Account\\AcceptInvite")
-      ->addRow("^/resetPassword(/)?$", "dynamic", "\\Documents\\Account", "\\Views\\Account\\ResetPassword")
+      ->addRow("^/register/?$", "dynamic", "\\Documents\\Account", "\\Views\\Account\\Register")
+      ->addRow("^/confirmEmail/?$", "dynamic", "\\Documents\\Account", "\\Views\\Account\\ConfirmEmail")
+      ->addRow("^/acceptInvite/?$", "dynamic", "\\Documents\\Account", "\\Views\\Account\\AcceptInvite")
+      ->addRow("^/resetPassword/?$", "dynamic", "\\Documents\\Account", "\\Views\\Account\\ResetPassword")
+      ->addRow("^/resendConfirmEmail/?$", "dynamic", "\\Documents\\Account", "\\Views\\Account\\ResendConfirmEmail")
       ->addRow("^/$", "static", "/static/welcome.html", NULL);
 
     $queries[] = $sql->createTable("Settings")

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

@@ -17,6 +17,7 @@ namespace Documents {
 namespace Documents\Account {
 
   use Elements\Head;
+  use Elements\Link;
   use Elements\Script;
   use Elements\SimpleBody;
 
@@ -32,6 +33,7 @@ namespace Documents\Account {
       $this->addJS(Script::ACCOUNT);
       $this->loadBootstrap();
       $this->loadFontawesome();
+      $this->addCSS(Link::CORE);
     }
 
     protected function initMetas(): array {

+ 22 - 0
core/Driver/SQL/Condition/Exists.class.php

@@ -0,0 +1,22 @@
+<?php
+
+
+namespace Driver\SQL\Condition;
+
+
+use Driver\SQL\Query\Select;
+
+class Exists extends Condition
+{
+    private Select $subQuery;
+
+    public function __construct(Select $subQuery)
+    {
+        $this->subQuery = $subQuery;
+    }
+
+    public function getSubQuery(): Select
+    {
+        return $this->subQuery;
+    }
+}

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

@@ -340,7 +340,7 @@ class PostgreSQL extends SQL {
     return ($statusTexts[$status] ?? "Unknown") . " (v$version)";
   }
 
-  protected function buildCondition($condition, &$params) {
+  public function buildCondition($condition, &$params) {
     if($condition instanceof CondRegex) {
       $left = $condition->getLeftExp();
       $right = $condition->getRightExp();

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

@@ -54,6 +54,11 @@ class AlterTable extends Query {
     return $this;
   }
 
+  public function resetAutoIncrement(): AlterTable {
+    $this->action = "RESET_AUTO_INCREMENT";
+    return $this;
+  }
+
   public function getAction(): string { return $this->action; }
   public function getColumn(): ?Column { return $this->column; }
   public function getConstraint(): ?Constraint { return $this->constraint; }
@@ -65,6 +70,10 @@ class AlterTable extends Query {
     $column = $this->getColumn();
     $constraint = $this->getConstraint();
 
+    if ($action === "RESET_AUTO_INCREMENT") {
+      return "ALTER TABLE $tableName AUTO_INCREMENT=1";
+    }
+
     $query = "ALTER TABLE $tableName $action ";
 
     if ($column) {

+ 23 - 1
core/Driver/SQL/Query/Select.class.php

@@ -23,6 +23,7 @@ class Select extends Query {
     $this->selectValues = (!empty($selectValues) && is_array($selectValues[0])) ? $selectValues[0] : $selectValues;
     $this->tables = array();
     $this->conditions = array();
+    $this->havings = array();
     $this->joins = array();
     $this->orderColumns = array();
     $this->groupColumns = array();
@@ -41,6 +42,11 @@ class Select extends Query {
     return $this;
   }
 
+  public function having(...$conditions): Select {
+    $this->havings[] = (count($conditions) === 1 ? $conditions : new CondOr($conditions));
+    return $this;
+  }
+
   public function innerJoin(string $table, string $columnA, string $columnB, ?string $tableAlias = null): Select {
     $this->joins[] = new Join("INNER", $table, $columnA, $columnB, $tableAlias);
     return $this;
@@ -94,6 +100,7 @@ class Select extends Query {
   public function getLimit(): int { return $this->limit; }
   public function getOffset(): int { return $this->offset; }
   public function getGroupBy(): array { return $this->groupColumns; }
+  public function getHavings(): array { return $this->havings; }
 
   public function build(array &$params): ?string {
 
@@ -101,6 +108,17 @@ class Select extends Query {
     foreach ($this->selectValues as $value) {
       if (is_string($value)) {
         $selectValues[] = $this->sql->columnName($value);
+      } else if ($value instanceof Select) {
+        $subSelect = $value->build($params);
+        if (count($value->getSelectValues()) !== 1) {
+          $selectValues[] = "($subSelect)";
+        } else {
+          $columnName = $value->getSelectValues()[0];
+          if(($index = stripos($columnName, " as ")) !== FALSE) {
+            $columnName = substr($columnName, $index + 4);
+          }
+          $selectValues[] = "($subSelect) as $columnName";
+        }
       } else {
         $selectValues[] = $this->sql->addValue($value, $params);
       }
@@ -115,6 +133,10 @@ class Select extends Query {
 
     $tables = $this->sql->tableName($tables);
     $where = $this->sql->getWhereClause($this->getConditions(), $params);
+    $havingClause = "";
+    if (count($this->havings) > 0) {
+      $havingClause  = " HAVING " . $this->sql->buildCondition($this->getHavings(), $params);
+    }
 
     $joinStr = "";
     $joins = $this->getJoins();
@@ -145,6 +167,6 @@ class Select extends Query {
 
     $limit = ($this->getLimit() > 0 ? (" LIMIT " . $this->getLimit()) : "");
     $offset = ($this->getOffset() > 0 ? (" OFFSET " . $this->getOffset()) : "");
-    return "SELECT $selectValues FROM $tables$joinStr$where$groupBy$orderBy$limit$offset";
+    return "SELECT $selectValues FROM $tables$joinStr$where$groupBy$havingClause$orderBy$limit$offset";
   }
 }

+ 13 - 3
core/Driver/SQL/SQL.class.php

@@ -4,6 +4,7 @@ namespace Driver\SQL;
 
 use Driver\SQL\Column\Column;
 use Driver\SQL\Condition\Compare;
+use Driver\SQL\Condition\CondAnd;
 use Driver\SQL\Condition\CondBool;
 use Driver\SQL\Condition\CondIn;
 use Driver\SQL\Condition\Condition;
@@ -11,6 +12,7 @@ use Driver\SQL\Condition\CondKeyword;
 use Driver\SQL\Condition\CondNot;
 use Driver\Sql\Condition\CondNull;
 use Driver\SQL\Condition\CondOr;
+use Driver\SQL\Condition\Exists;
 use Driver\SQL\Constraint\Constraint;
 use \Driver\SQL\Constraint\Unique;
 use \Driver\SQL\Constraint\PrimaryKey;
@@ -234,7 +236,7 @@ abstract class SQL {
   // Statements
   protected abstract function execute($query, $values=NULL, $returnValues=false);
 
-  protected function buildCondition($condition, &$params) {
+  public function buildCondition($condition, &$params) {
 
     if ($condition instanceof CondOr) {
       $conditions = array();
@@ -242,6 +244,12 @@ abstract class SQL {
         $conditions[] = $this->buildCondition($cond, $params);
       }
       return "(" . implode(" OR ", $conditions) . ")";
+    } else if ($condition instanceof CondAnd) {
+      $conditions = array();
+      foreach($condition->getConditions() as $cond) {
+        $conditions[] = $this->buildCondition($cond, $params);
+      }
+      return "(" . implode(" AND ", $conditions) . ")";
     } else if ($condition instanceof Compare) {
       $column = $this->columnName($condition->getColumn());
       $value = $condition->getValue();
@@ -302,8 +310,10 @@ abstract class SQL {
       }
 
       return "NOT $expression";
-    } else if($condition instanceof CondNull) {
-      return $this->columnName($condition->getColumn()) . " IS NULL";
+    } else if ($condition instanceof CondNull) {
+        return $this->columnName($condition->getColumn()) . " IS NULL";
+    } else if ($condition instanceof Exists) {
+        return "EXISTS(" .$condition->getSubQuery()->build($params) . ")";
     } else {
       $this->lastError = "Unsupported condition type: " . get_class($condition);
       return null;

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

@@ -11,7 +11,8 @@ class Script extends StaticView {
   const INSTALL   = "/js/install.js";
   const BOOTSTRAP = "/js/bootstrap.bundle.min.js";
   const ACCOUNT   = "/js/account.js";
-  const FILES     = "/js/files.min.js";
+  const SECLAB    = "/js/seclab.min.js";
+  const FONTAWESOME = "/js/fontawesome-all.min.js";
 
   private string $type;
   private string $content;

+ 7 - 0
core/External/ZipStream/File.php

@@ -68,6 +68,13 @@ 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;
     }

+ 126 - 0
core/Objects/AesStream.class.php

@@ -0,0 +1,126 @@
+<?php
+
+namespace Objects;
+
+class AesStream {
+
+  private string $key;
+  private string $iv;
+  private $callback;
+  private ?string $outputFile;
+  private ?string $inputFile;
+
+  public function __construct(string $key, string $iv) {
+    $this->key = $key;
+    $this->iv  = $iv;
+    $this->inputFile = null;
+    $this->outputFile = null;
+    $this->callback = null;
+
+    if (!in_array(strlen($key), [16, 24, 32])) {
+      throw new \Exception("Invalid Key Size");
+    } else if (strlen($iv) !== 16) {
+      throw new \Exception("Invalid IV Size");
+    }
+  }
+
+  public function setInput($file) {
+    $this->inputFile = $file;
+  }
+
+  public function setOutput($callback) {
+    $this->callback = $callback;
+  }
+
+  public function setOutputFile(string $file) {
+    $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";
+
+    $outputHandle = null;
+    $inputHandle = fopen($this->inputFile, "rb");
+    if (!$inputHandle) {
+      return false;
+    }
+
+    if ($this->outputFile !== null) {
+      $outputHandle = fopen($this->outputFile, "wb");
+      if (!$outputHandle) {
+        return false;
+      }
+    }
+
+    $counter = 0;
+    while (!feof($inputHandle)) {
+      $chunk = fread($inputHandle, 4096);
+      $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);
+        }
+
+        $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)) {
+          call_user_func($this->callback, $encrypted);
+        }
+
+        if ($outputHandle !== null) {
+          fwrite($outputHandle, $encrypted);
+        }
+      }
+    }
+
+    fclose($inputHandle);
+    if ($outputHandle) fclose($outputHandle);
+    return true;
+  }
+}

+ 1 - 1
core/Objects/Session.class.php

@@ -66,7 +66,7 @@ class Session extends ApiObject {
     $token = array('userId' => $this->user->getId(), 'sessionId' => $this->sessionId);
     $sessionCookie = JWT::encode($token, $settings->getJwtSecret());
     $secure = strcmp(getProtocol(), "https") === 0;
-    setcookie('session', $sessionCookie, $this->getExpiresTime(), "/", "", $secure);
+    setcookie('session', $sessionCookie, $this->getExpiresTime(), "/", "", $secure, true);
   }
 
   public function getExpiresTime(): int {

+ 9 - 2
core/Objects/User.class.php

@@ -27,7 +27,7 @@ class User extends ApiObject {
     $this->connectDb();
 
     if (!is_cli()) {
-      session_start();
+      @session_start();
       $this->setLanguage(Language::DEFAULT_LANGUAGE());
       $this->parseCookies();
     }
@@ -227,9 +227,12 @@ class User extends ApiObject {
     }
 
     $res = $this->sql->select("ApiKey.user_id as uid", "User.name", "User.email", "User.confirmed",
-      "Language.uid as langId", "Language.code as langCode", "Language.name as langName")
+      "Language.uid as langId", "Language.code as langCode", "Language.name as langName",
+      "Group.uid as groupId", "Group.name as groupName")
       ->from("ApiKey")
       ->innerJoin("User", "ApiKey.user_id", "User.uid")
+      ->leftJoin("UserGroup", "UserGroup.user_id", "User.uid")
+      ->leftJoin("Group", "UserGroup.group_id", "Group.uid")
       ->leftJoin("Language", "User.language_id", "Language.uid")
       ->where(new Compare("ApiKey.api_key", $apiKey))
       ->where(new Compare("valid_until", $this->sql->currentTimestamp(), ">"))
@@ -253,6 +256,10 @@ class User extends ApiObject {
         if(!is_null($row['langId'])) {
           $this->setLanguage(Language::newInstance($row['langId'], $row['langCode'], $row['langName']));
         }
+
+        foreach($res as $row) {
+          $this->groups[$row["groupId"]] = $row["groupName"];
+        }
       }
     }
 

+ 2 - 2
core/Views/Account/AcceptInvite.class.php

@@ -73,13 +73,13 @@ class AcceptInvite extends AccountView {
           <div class=\"input-group-append\">
             <span class=\"input-group-text\"><i class=\"fas fa-key\"></i></span>
           </div>
-          <input type=\"password\" name='password' id='password' class=\"form-control\" placeholder=\"Password\">
+          <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\" name='confirmPassword' id='confirmPassword' class=\"form-control\" placeholder=\"Confirm Password\">
+          <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>

+ 2 - 2
core/Views/Account/AccountView.class.php

@@ -34,14 +34,14 @@ abstract class AccountView extends View {
 
     $html .= "<div class=\"container mt-5\">
         <div class=\"row\">
-          <div class=\"col-md-4 py-5 bg-primary text-white text-center\" style='border-top-left-radius:.4em;border-bottom-left-radius:.4em'>
+          <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-8 pt-5 pb-2 border border-info\" style='border-top-right-radius:.4em;border-bottom-right-radius:.4em'>
+          <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>

+ 32 - 23
core/Views/Account/ConfirmEmail.class.php

@@ -5,42 +5,51 @@ namespace Views\Account;
 
 
 use Elements\Document;
+use Elements\Script;
 
 class ConfirmEmail extends AccountView {
 
-  private bool $success;
-  private string $message;
-
   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";
-    $this->success = false;
-    $this->message = "No content";
   }
 
   public function loadView() {
     parent::loadView();
-
-    if (isset($_GET["token"]) && is_string($_GET["token"]) && !empty($_GET["token"])) {
-      $req = new \Api\User\ConfirmEmail($this->getDocument()->getUser());
-      $this->success = $req->execute(array("token" => $_GET["token"]));
-      if ($this->success) {
-        $this->message = "Your e-mail address was successfully confirmed, you may now log in";
-      } else {
-        $this->message = "Error confirming e-mail address: " . $req->getLastError();
-      }
-    } else {
-      $this->success = false;
-      $this->message = "The link you visited is no longer valid";
-    }
+    $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() {
-    if ($this->success) {
-      return $this->createSuccessText($this->message);
-    } else {
-      return $this->createErrorText($this->message);
-    }
+
+    $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;
   }
 }

+ 18 - 6
core/Views/Account/Register.class.php

@@ -16,7 +16,14 @@ class Register extends AccountView {
 
   public function getAccountContent() {
 
-    $settings = $this->getDocument()->getUser()->getConfiguration()->getSettings();
+    $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,
@@ -30,28 +37,33 @@ class Register extends AccountView {
           <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\">
+          <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\" name='email' id='email' class=\"form-control\" placeholder=\"Email\" maxlength=\"64\">
+          <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\" name='password' id='password' class=\"form-control\" placeholder=\"Password\">
+          <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\" name='confirmPassword' id='confirmPassword' class=\"form-control\" placeholder=\"Confirm Password\">
+          <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='btnRegister'>Submit</button>
+          <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>";
   }

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

@@ -0,0 +1,39 @@
+<?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>
+      ";
+  }
+}

+ 12 - 5
core/Views/Account/ResetPassword.class.php

@@ -56,10 +56,17 @@ class ResetPassword extends AccountView {
           <div class=\"input-group-append\">
             <span class=\"input-group-text\"><i class=\"fas fa-at\"></i></span>  
           </div>
-          <input id=\"email\" name=\"email\" placeholder=\"E-Mail address\" class=\"form-control\" type=\"email\" maxlength=\"64\" />
+          <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\">
-          <button id='btnRequestPasswordReset' class='btn btn-primary'>Request</button>
+        <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 {
@@ -70,13 +77,13 @@ class ResetPassword extends AccountView {
           <div class=\"input-group-append\">
             <span class=\"input-group-text\"><i class=\"fas fa-key\"></i></span>
           </div>
-          <input type=\"password\" name='password' id='password' class=\"form-control\" placeholder=\"Password\">
+          <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\" name='confirmPassword' id='confirmPassword' class=\"form-control\" placeholder=\"Confirm Password\">
+          <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>

+ 21 - 9
core/core.php

@@ -1,11 +1,11 @@
 <?php
 
-define("WEBBASE_VERSION", "1.2.5");
+define("WEBBASE_VERSION", "1.3.0");
 
 spl_autoload_extensions(".php");
 spl_autoload_register(function($class) {
-  $full_path = getClassPath($class);
-  if(file_exists($full_path)) {
+  $full_path = WEBROOT . "/" . getClassPath($class);
+  if (file_exists($full_path)) {
     include_once $full_path;
   } else {
     include_once getClassPath($class, false);
@@ -24,10 +24,24 @@ function getProtocol(): string {
   return $isSecure ? 'https' : 'http';
 }
 
-function generateRandomString($length): string {
+function generateRandomString($length, $type = "ascii"): string {
   $randomString = '';
+
+  $lowercase = "abcdefghijklmnopqrstuvwxyz";
+  $uppercase = strtoupper($lowercase);
+  $digits    = "0123456789";
+  $hex       = $digits . substr($lowercase, 0, 6);
+  $ascii     = $lowercase . $uppercase . $digits;
+
   if ($length > 0) {
-    $numCharacters = 26 + 26 + 10; // a-z + A-Z + 0-9
+    $type = strtolower($type);
+    if ($type === "hex") {
+      $charset = $hex;
+    } else {
+      $charset = $ascii;
+    }
+
+    $numCharacters = strlen($charset);
     for ($i = 0; $i < $length; $i++) {
       try {
         $num = random_int(0, $numCharacters - 1);
@@ -35,9 +49,7 @@ function generateRandomString($length): string {
         $num = rand(0, $numCharacters - 1);
       }
 
-      if ($num < 26) $randomString .= chr(ord('a') + $num);
-      else if ($num - 26 < 26) $randomString .= chr(ord('A') + $num - 26);
-      else $randomString .= chr(ord('0') + $num - 26 - 26);
+      $randomString .= $charset[$num];
     }
   }
 
@@ -124,7 +136,7 @@ function urlId($str) {
   return urlencode(htmlspecialchars(preg_replace("[: ]","-", $str)));
 }
 
-function getClassPath($class, $suffix = true) {
+function getClassPath($class, $suffix = true): string {
   $path = str_replace('\\', '/', $class);
   $path = array_values(array_filter(explode("/", $path)));
 

+ 1 - 2
core/datetime.php

@@ -95,8 +95,7 @@ function getMonthName($month) {
 
 function isInPast($d) {
   $now = date('Y-m-d H:i:s');
-  if(is_a($d, "DateTime")) $d = $d->format('Y-m-d H:i:s');
-  return (strtotime($d) < strtotime($now));
+  return datetimeDiff($d, $now) > 0;
 }
 
 function datetimeDiff($d1, $d2) {

+ 1 - 0
img/.htaccess

@@ -0,0 +1 @@
+php_flag engine off

+ 1 - 0
img/icons/files/.htaccess

@@ -0,0 +1 @@
+php_flag engine on

+ 42 - 8
js/account.js

@@ -4,9 +4,13 @@ $(document).ready(function () {
         return (typeof grecaptcha !== 'undefined');
     }
 
-    function showAlert(type, msg) {
+    function showAlert(type, msg, raw=false) {
         let alert = $("#alertMessage");
-        alert.text(msg);
+        if (raw) {
+            alert.html(msg);
+        } else {
+            alert.text(msg);
+        }
         alert.attr("class", "mt-2 alert alert-" + type);
         alert.show();
     }
@@ -51,7 +55,11 @@ $(document).ready(function () {
                 btn.prop("disabled", false);
                 $("#password").val("");
                 createdDiv.hide();
-                showAlert("danger", res.msg);
+                if (res.emailConfirmed === false) {
+                    showAlert("danger", res.msg + ' <a href="/resendConfirmation">Click here</a> to resend the confirmation mail.', true);
+                } else {
+                    showAlert("danger", res.msg);
+                }
             }
         });
     });
@@ -79,14 +87,14 @@ $(document).ready(function () {
                         params["captcha"] = captcha;
                         submitForm(btn, "user/register", params, () => {
                             showAlert("success", "Account successfully created, check your emails.");
-                            $("input").val("");
+                            $("input:not([id='siteKey'])").val("");
                         });
                     });
                 });
             } else {
                 submitForm(btn, "user/register", params, () => {
                     showAlert("success", "Account successfully created, check your emails.");
-                    $("input").val("");
+                    $("input:not([id='siteKey'])").val("");
                 });
             }
         }
@@ -137,14 +145,14 @@ $(document).ready(function () {
                     params["captcha"] = captcha;
                     submitForm(btn, "user/requestPasswordReset", params, () => {
                         showAlert("success", "If the e-mail address exists and is linked to a account, you will receive a password reset token.");
-                        $("input").val("");
+                        $("input:not([id='siteKey'])").val("");
                     });
                 });
             });
         } else {
             submitForm(btn, "user/requestPasswordReset", params, () => {
                 showAlert("success", "If the e-mail address exists and is linked to a account, you will receive a password reset token.");
-                $("input").val("");
+                $("input:not([id='siteKey'])").val("");
             });
         }
     });
@@ -173,9 +181,35 @@ $(document).ready(function () {
                     showAlert("danger", res.msg);
                 } else {
                     showAlert("success", "Your password was successfully changed. You may now login.");
-                    $("input").val("");
+                    $("input:not([id='siteKey'])").val("");
                 }
             });
         }
     });
+
+    $("#btnResendConfirmEmail").click(function(e) {
+        e.preventDefault();
+        e.stopPropagation();
+
+        let btn = $(this);
+        let email = $("#email").val();
+        let params = { email: email };
+        if (isRecaptchaEnabled()) {
+            let siteKey = $("#siteKey").val().trim();
+            grecaptcha.ready(function() {
+                grecaptcha.execute(siteKey, {action: 'resendConfirmation'}).then(function(captcha) {
+                    params["captcha"] = captcha;
+                    submitForm(btn, "user/resendConfirmEmail", params, () => {
+                        showAlert("success", "If the e-mail address exists and is linked to a account, you will receive a new confirmation email.");
+                        $("input:not([id='siteKey'])").val("");
+                    });
+                });
+            });
+        } else {
+            submitForm(btn, "user/resendConfirmEmail", params, () => {
+                showAlert("success", "\"If the e-mail address exists and is linked to a account, you will receive a new confirmation email.");
+                $("input:not([id='siteKey'])").val("");
+            });
+        }
+    });
 });

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 4 - 0
js/fontawesome-all.min.js


이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.