diff --git a/.htaccess b/.htaccess index c7e4846..bd3f44c 100644 --- a/.htaccess +++ b/.htaccess @@ -3,6 +3,7 @@ Options -Indexes DirectorySlash Off +SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1 RewriteEngine On RewriteRule ^api(/.*)?$ /index.php?api=$1 [L,QSA] diff --git a/cli.php b/cli.php index 1159071..e3878d1 100644 --- a/cli.php +++ b/cli.php @@ -1,5 +1,7 @@ user->getSQL(); $res = $sql->select($sql->count()) ->from("ApiKey") diff --git a/core/Api/PermissionAPI.class.php b/core/Api/PermissionAPI.class.php index dd91f75..0cdc1d4 100644 --- a/core/Api/PermissionAPI.class.php +++ b/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; } diff --git a/core/Api/Request.class.php b/core/Api/Request.class.php index 4e42e6c..41d2c36 100644 --- a/core/Api/Request.class.php +++ b/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(); + } } \ No newline at end of file diff --git a/core/Api/RoutesAPI.class.php b/core/Api/RoutesAPI.class.php index da96438..8086ab2 100644 --- a/core/Api/RoutesAPI.class.php +++ b/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(); diff --git a/core/Api/UserAPI.class.php b/core/Api/UserAPI.class.php index 8083657..9dcdf5b 100644 --- a/core/Api/UserAPI.class.php +++ b/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; } + } - private function insertToken(int $id, string $token) { - $validUntil = (new DateTime())->modify("+1 hour"); + 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; + } + + $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; + } + } } \ No newline at end of file diff --git a/core/Api/VerifyCaptcha.class.php b/core/Api/VerifyCaptcha.class.php index 51ae427..07f2a44 100644 --- a/core/Api/VerifyCaptcha.class.php +++ b/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"]; diff --git a/core/Configuration/CreateDatabase.class.php b/core/Configuration/CreateDatabase.class.php index 4a9ea26..33cc5fb 100644 --- a/core/Configuration/CreateDatabase.class.php +++ b/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") diff --git a/core/Documents/Account.class.php b/core/Documents/Account.class.php index 27c6843..b8dc6d6 100644 --- a/core/Documents/Account.class.php +++ b/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 { diff --git a/core/Driver/SQL/Condition/Exists.class.php b/core/Driver/SQL/Condition/Exists.class.php new file mode 100644 index 0000000..ab45af9 --- /dev/null +++ b/core/Driver/SQL/Condition/Exists.class.php @@ -0,0 +1,22 @@ +subQuery = $subQuery; + } + + public function getSubQuery(): Select + { + return $this->subQuery; + } +} \ No newline at end of file diff --git a/core/Driver/SQL/PostgreSQL.class.php b/core/Driver/SQL/PostgreSQL.class.php index 7a8a40b..4fa67be 100644 --- a/core/Driver/SQL/PostgreSQL.class.php +++ b/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(); diff --git a/core/Driver/SQL/Query/AlterTable.class.php b/core/Driver/SQL/Query/AlterTable.class.php index 2af16a5..15e4841 100644 --- a/core/Driver/SQL/Query/AlterTable.class.php +++ b/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) { diff --git a/core/Driver/SQL/Query/Select.class.php b/core/Driver/SQL/Query/Select.class.php index bf51178..d3e83e4 100644 --- a/core/Driver/SQL/Query/Select.class.php +++ b/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"; } } \ No newline at end of file diff --git a/core/Driver/SQL/SQL.class.php b/core/Driver/SQL/SQL.class.php index 21cdcf5..508a35e 100644 --- a/core/Driver/SQL/SQL.class.php +++ b/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; diff --git a/core/Elements/Script.class.php b/core/Elements/Script.class.php index 21dd4d4..74a6953 100644 --- a/core/Elements/Script.class.php +++ b/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; diff --git a/core/External/ZipStream/File.php b/core/External/ZipStream/File.php index 3aa22a6..117a73b 100644 --- a/core/External/ZipStream/File.php +++ b/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; } diff --git a/core/Objects/AesStream.class.php b/core/Objects/AesStream.class.php new file mode 100644 index 0000000..247b717 --- /dev/null +++ b/core/Objects/AesStream.class.php @@ -0,0 +1,126 @@ +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; + } +} \ No newline at end of file diff --git a/core/Objects/Session.class.php b/core/Objects/Session.class.php index 7053787..eeaa193 100644 --- a/core/Objects/Session.class.php +++ b/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 { diff --git a/core/Objects/User.class.php b/core/Objects/User.class.php index cd48215..19a7081 100644 --- a/core/Objects/User.class.php +++ b/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"]; + } } } diff --git a/core/Views/Account/AcceptInvite.class.php b/core/Views/Account/AcceptInvite.class.php index d1c7c4b..8dc47c9 100644 --- a/core/Views/Account/AcceptInvite.class.php +++ b/core/Views/Account/AcceptInvite.class.php @@ -73,13 +73,13 @@ class AcceptInvite extends AccountView {
- +
- +
diff --git a/core/Views/Account/AccountView.class.php b/core/Views/Account/AccountView.class.php index 6c6907d..f14e033 100644 --- a/core/Views/Account/AccountView.class.php +++ b/core/Views/Account/AccountView.class.php @@ -34,14 +34,14 @@ abstract class AccountView extends View { $html .= "
-
+
$icon

$this->title

$this->description

-
+
$content
diff --git a/core/Views/Account/ConfirmEmail.class.php b/core/Views/Account/ConfirmEmail.class.php index 47abe06..8f77287 100644 --- a/core/Views/Account/ConfirmEmail.class.php +++ b/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 = " +
+ Confirming email… $spinner +
"; + + $html .= ""; + return $html; } } \ No newline at end of file diff --git a/core/Views/Account/Register.class.php b/core/Views/Account/Register.class.php index 3cb384a..2a7108e 100644 --- a/core/Views/Account/Register.class.php +++ b/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 {
- +
- +
- +
- +
- + + + +
"; } diff --git a/core/Views/Account/ResendConfirmEmail.class.php b/core/Views/Account/ResendConfirmEmail.class.php new file mode 100644 index 0000000..30cad73 --- /dev/null +++ b/core/Views/Account/ResendConfirmEmail.class.php @@ -0,0 +1,39 @@ +title = "Resend Confirm Email"; + $this->description = "Request a new confirmation email to finalize the account creation"; + $this->icon = "envelope"; + } + + protected function getAccountContent() { + return "

Enter your E-Mail address, to receive a new e-mail to confirm your registration.

+
+
+
+ +
+ +
+
+ + + + +
+ "; + } +} \ No newline at end of file diff --git a/core/Views/Account/ResetPassword.class.php b/core/Views/Account/ResetPassword.class.php index ba6a4b2..28d013f 100644 --- a/core/Views/Account/ResetPassword.class.php +++ b/core/Views/Account/ResetPassword.class.php @@ -56,10 +56,17 @@ class ResetPassword extends AccountView {
- +
-
- +
+ + + +
"; } else { @@ -70,13 +77,13 @@ class ResetPassword extends AccountView {
- +
- +
diff --git a/core/core.php b/core/core.php index 333db8d..ef65d23 100644 --- a/core/core.php +++ b/core/core.php @@ -1,11 +1,11 @@ 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))); diff --git a/core/datetime.php b/core/datetime.php index 377c1bc..2a7a7b0 100644 --- a/core/datetime.php +++ b/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) { diff --git a/img/.htaccess b/img/.htaccess new file mode 100644 index 0000000..ddc33ea --- /dev/null +++ b/img/.htaccess @@ -0,0 +1 @@ +php_flag engine off \ No newline at end of file diff --git a/img/icons/files/.htaccess b/img/icons/files/.htaccess new file mode 100644 index 0000000..1cf6def --- /dev/null +++ b/img/icons/files/.htaccess @@ -0,0 +1 @@ +php_flag engine on \ No newline at end of file diff --git a/js/account.js b/js/account.js index 0f8395e..c446e0a 100644 --- a/js/account.js +++ b/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 + ' Click here 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(""); + }); + } + }); }); diff --git a/js/fontawesome-all.min.js b/js/fontawesome-all.min.js new file mode 100644 index 0000000..5b3ac9b --- /dev/null +++ b/js/fontawesome-all.min.js @@ -0,0 +1,5 @@ +/*! + * Font Awesome Free 5.15.4 by @fontawesome - https://fontawesome.com + * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) + */ +!function(){"use strict";var c={},l={};try{"undefined"!=typeof window&&(c=window),"undefined"!=typeof document&&(l=document)}catch(c){}var h=(c.navigator||{}).userAgent,a=void 0===h?"":h,z=c,v=l,m=(z.document,!!v.documentElement&&!!v.head&&"function"==typeof v.addEventListener&&v.createElement,~a.indexOf("MSIE")||a.indexOf("Trident/"),"___FONT_AWESOME___"),e=function(){try{return!0}catch(c){return!1}}();var s=z||{};s[m]||(s[m]={}),s[m].styles||(s[m].styles={}),s[m].hooks||(s[m].hooks={}),s[m].shims||(s[m].shims=[]);var t=s[m];function M(c,a){var l=(2>>0;h--;)l[h]=c[h];return l}function Ac(c){return c.classList?bc(c.classList):(c.getAttribute("class")||"").split(" ").filter(function(c){return c})}function gc(c,l){var h,a=l.split("-"),z=a[0],v=a.slice(1).join("-");return z!==c||""===v||(h=v,~T.indexOf(h))?null:v}function Sc(c){return"".concat(c).replace(/&/g,"&").replace(/"/g,""").replace(/'/g,"'").replace(//g,">")}function yc(h){return Object.keys(h||{}).reduce(function(c,l){return c+"".concat(l,": ").concat(h[l],";")},"")}function wc(c){return c.size!==Lc.size||c.x!==Lc.x||c.y!==Lc.y||c.rotate!==Lc.rotate||c.flipX||c.flipY}function Zc(c){var l=c.transform,h=c.containerWidth,a=c.iconWidth,z={transform:"translate(".concat(h/2," 256)")},v="translate(".concat(32*l.x,", ").concat(32*l.y,") "),m="scale(".concat(l.size/16*(l.flipX?-1:1),", ").concat(l.size/16*(l.flipY?-1:1),") "),e="rotate(".concat(l.rotate," 0 0)");return{outer:z,inner:{transform:"".concat(v," ").concat(m," ").concat(e)},path:{transform:"translate(".concat(a/2*-1," -256)")}}}var kc={x:0,y:0,width:"100%",height:"100%"};function xc(c){var l=!(1").concat(m.map(Jc).join(""),"")}var $c=function(){};function cl(c){return"string"==typeof(c.getAttribute?c.getAttribute(cc):null)}var ll={replace:function(c){var l=c[0],h=c[1].map(function(c){return Jc(c)}).join("\n");if(l.parentNode&&l.outerHTML)l.outerHTML=h+(lc.keepOriginalSource&&"svg"!==l.tagName.toLowerCase()?"\x3c!-- ".concat(l.outerHTML," Font Awesome fontawesome.com --\x3e"):"");else if(l.parentNode){var a=document.createElement("span");l.parentNode.replaceChild(a,l),a.outerHTML=h}},nest:function(c){var l=c[0],h=c[1];if(~Ac(l).indexOf(lc.replacementClass))return ll.replace(c);var a=new RegExp("".concat(lc.familyPrefix,"-.*"));delete h[0].attributes.style,delete h[0].attributes.id;var z=h[0].attributes.class.split(" ").reduce(function(c,l){return l===lc.replacementClass||l.match(a)?c.toSvg.push(l):c.toNode.push(l),c},{toNode:[],toSvg:[]});h[0].attributes.class=z.toSvg.join(" ");var v=h.map(function(c){return Jc(c)}).join("\n");l.setAttribute("class",z.toNode.join(" ")),l.setAttribute(cc,""),l.innerHTML=v}};function hl(c){c()}function al(h,c){var a="function"==typeof c?c:$c;if(0===h.length)a();else{var l=hl;lc.mutateApproach===y&&(l=o.requestAnimationFrame||hl),l(function(){var c=!0===lc.autoReplaceSvg?ll.replace:ll[lc.autoReplaceSvg]||ll.replace,l=_c.begin("mutate");h.map(c),l(),a()})}}var zl=!1;function vl(){zl=!1}var ml=null;function el(c){if(t&&lc.observeMutations){var z=c.treeCallback,v=c.nodeCallback,m=c.pseudoElementsCallback,l=c.observeMutationsRoot,h=void 0===l?C:l;ml=new t(function(c){zl||bc(c).forEach(function(c){if("childList"===c.type&&0