This commit is contained in:
Roman 2021-11-11 14:25:26 +01:00
parent 1737a2f592
commit 25d47f7528
32 changed files with 633 additions and 121 deletions

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

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

@ -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;
}

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

@ -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();

@ -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;
}
}
}

@ -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"];

@ -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")

@ -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 {

@ -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;
}
}

@ -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();

@ -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,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";
}
}

@ -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;

@ -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;

@ -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;
}

@ -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;
}
}

@ -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 {

@ -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"];
}
}
}

@ -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>

@ -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>

@ -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;
}
}

@ -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>";
}

@ -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>
";
}
}

@ -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>

@ -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)));

@ -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
img/.htaccess Normal file

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

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

@ -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("");
});
}
});
});

5
js/fontawesome-all.min.js vendored Normal file

File diff suppressed because one or more lines are too long