Core Update 1.4.0

This commit is contained in:
Roman 2022-02-20 16:53:26 +01:00
parent 918244125c
commit bd1f302433
86 changed files with 3301 additions and 41128 deletions

@ -9,10 +9,6 @@ RewriteRule ^api(/.*)?$ /index.php?api=$1 [L,QSA]
RewriteEngine On
RewriteOptions AllowNoSlash
RewriteRule ^files$ /files/ [L,QSA]
RewriteEngine On
RewriteOptions AllowNoSlash
RewriteRule ^((\.idea|\.git|src|test|core)(/.*)?)$ /index.php?site=$1 [L,QSA]
RewriteRule ^((\.idea|\.git|src|test|core|docker|files)(/.*)?)$ /index.php?site=$1 [L,QSA]
FallbackResource /index.php

@ -36,7 +36,7 @@ I actually don't know what i want to implement here. There are quite to many CMS
### Afterwards
For any changes made in [/adminPanel](/adminPanel) or [/fileControlPanel](/fileControlPanel), run:
For any changes made in [/adminPanel](/adminPanel), run:
1. once: `npm i`
2. build: `npm run build`
The compiled dist files will be automatically moved to `/js`.

35
cli.php

@ -590,14 +590,42 @@ function onMail($argv) {
}
_exit("Done.");
} else if ($action === "send_news") {
$user = getUser() or die();
$debug = in_array("debug", $argv);
$req = new \Api\Mail\SendNews($user);
if (!$req->execute(["debug" => $debug])) {
_exit("Error sending news mails: " . $req->getLastError());
}
_exit("Done.");
} else if ($action === "send_queue") {
$user = getUser() or die();
$req = new \Api\Mail\SendQueue($user);
$debug = in_array("debug", $argv);
if (!$req->execute(["debug" => $debug])) {
_exit("Error processing mail queue: " . $req->getLastError());
}
} else {
_exit("Usage: cli.php mail <sync> [options...]");
_exit("Usage: cli.php mail <sync|send_news|send_queue> [options...]");
}
}
function onImpersonate($argv) {
if (count($argv) < 3) {
_exit("Usage: cli.php impersonate <user_id>");
}
$user = getUser() or exit;
$user->createSession(intval($argv[2]));
$session = $user->getSession();
$session->setData(["2faAuthenticated" => true]);
echo "session=" . $session->getCookie() . PHP_EOL;
}
$argv = $_SERVER['argv'];
if (count($argv) < 2) {
_exit("Usage: cli.php <db|routes|settings|maintenance> [options...]");
_exit("Usage: cli.php <db|routes|settings|maintenance|impersonate> [options...]");
}
$command = $argv[1];
@ -623,6 +651,9 @@ switch ($command) {
case 'settings':
onSettings($argv);
break;
case 'impersonate':
onImpersonate($argv);
break;
default:
printLine("Unknown command '$command'");
printLine();

@ -32,6 +32,7 @@ namespace Api\ApiKey {
use Api\ApiKeyAPI;
use Api\Parameter\Parameter;
use Api\Request;
use DateTime;
use Driver\SQL\Condition\Compare;
use Exception;
@ -64,12 +65,11 @@ namespace Api\ApiKey {
if ($this->success) {
$this->result["api_key"] = array(
"api_key" => $apiKey,
"valid_until" => $validUntil->getTimestamp(),
"valid_until" => $validUntil->format("Y-m-d H:i:s"),
"uid" => $sql->getLastInsertId(),
);
} else {
$this->result["api_key"] = null;
}
return $this->success;
}
}
@ -77,7 +77,9 @@ namespace Api\ApiKey {
class Fetch extends ApiKeyAPI {
public function __construct($user, $externalCall = false) {
parent::__construct($user, $externalCall, array());
parent::__construct($user, $externalCall, array(
"showActiveOnly" => new Parameter("showActiveOnly", Parameter::TYPE_BOOLEAN, true, true)
));
$this->loginRequired = true;
}
@ -87,12 +89,16 @@ namespace Api\ApiKey {
}
$sql = $this->user->getSQL();
$res = $sql->select("uid", "api_key", "valid_until")
$query = $sql->select("uid", "api_key", "valid_until", "active")
->from("ApiKey")
->where(new Compare("user_id", $this->user->getId()))
->where(new Compare("valid_until", $sql->currentTimestamp(), ">"))
->where(new Compare("active", true))
->execute();
->where(new Compare("user_id", $this->user->getId()));
if ($this->getParam("showActiveOnly")) {
$query->where(new Compare("valid_until", $sql->currentTimestamp(), ">"))
->where(new Compare("active", true));
}
$res = $query->execute();
$this->success = ($res !== FALSE);
$this->lastError = $sql->getLastError();
@ -100,16 +106,12 @@ namespace Api\ApiKey {
if($this->success) {
$this->result["api_keys"] = array();
foreach($res as $row) {
try {
$validUntil = (new DateTime($row["valid_until"]))->getTimestamp();
} catch (Exception $e) {
$validUntil = $row["valid_until"];
}
$this->result["api_keys"][] = array(
"uid" => intval($row["uid"]),
$apiKeyId = intval($row["uid"]);
$this->result["api_keys"][$apiKeyId] = array(
"id" => $apiKeyId,
"api_key" => $row["api_key"],
"valid_until" => $validUntil,
"valid_until" => $row["valid_until"],
"revoked" => !$sql->parseBool($row["active"])
);
}
}
@ -146,7 +148,7 @@ namespace Api\ApiKey {
$this->lastError = $sql->getLastError();
if ($this->success) {
$this->result["valid_until"] = $validUntil->getTimestamp();
$this->result["valid_until"] = $validUntil;
}
return $this->success;
@ -168,7 +170,7 @@ namespace Api\ApiKey {
}
$id = $this->getParam("id");
if(!$this->apiKeyExists($id))
if (!$this->apiKeyExists($id))
return false;
$sql = $this->user->getSQL();

File diff suppressed because it is too large Load Diff

@ -25,6 +25,7 @@ namespace Api {
$connectionData = new ConnectionData($host, $port, $login, $password);
$connectionData->setProperty("from", $settings["mail_from"] ?? "");
$connectionData->setProperty("last_sync", $settings["mail_last_sync"] ?? "");
$connectionData->setProperty("mail_footer", $settings["mail_footer"] ?? "");
return $connectionData;
}
@ -41,22 +42,20 @@ namespace Api\Mail {
use DateTimeInterface;
use Driver\SQL\Column\Column;
use Driver\SQL\Condition\Compare;
use Driver\SQL\Condition\CondBool;
use Driver\SQL\Condition\CondIn;
use Driver\SQL\Condition\CondNot;
use Driver\SQL\Expression\CurrentTimeStamp;
use Driver\SQL\Expression\JsonArrayAgg;
use Driver\SQL\Strategy\UpdateStrategy;
use External\PHPMailer\Exception;
use External\PHPMailer\PHPMailer;
use Objects\ConnectionData;
use Objects\GpgKey;
use Objects\User;
class Test extends MailAPI {
public function __construct(User $user, bool $externalCall = false) {
parent::__construct($user, $externalCall, array(
"receiver" => new Parameter("receiver", Parameter::TYPE_EMAIL)
"receiver" => new Parameter("receiver", Parameter::TYPE_EMAIL),
"gpgFingerprint" => new StringType("gpgFingerprint", 64, true, null)
));
}
@ -70,7 +69,9 @@ namespace Api\Mail {
$this->success = $req->execute(array(
"to" => $receiver,
"subject" => "Test E-Mail",
"body" => "Hey! If you receive this e-mail, your mail configuration seems to be working."
"body" => "Hey! If you receive this e-mail, your mail configuration seems to be working.",
"gpgFingerprint" => $this->getParam("gpgFingerprint"),
"asnyc" => false
));
$this->lastError = $req->getLastError();
@ -78,6 +79,7 @@ namespace Api\Mail {
}
}
// TODO: expired gpg keys?
class Send extends MailAPI {
public function __construct($user, $externalCall = false) {
parent::__construct($user, $externalCall, array(
@ -85,7 +87,9 @@ namespace Api\Mail {
'subject' => new StringType('subject', -1),
'body' => new StringType('body', -1),
'replyTo' => new Parameter('replyTo', Parameter::TYPE_EMAIL, true, null),
'replyName' => new StringType('replyName', 32, true, "")
'replyName' => new StringType('replyName', 32, true, ""),
"gpgFingerprint" => new StringType("gpgFingerprint", 64, true, null),
'async' => new Parameter("async", Parameter::TYPE_BOOLEAN, true, true)
));
$this->isPublic = false;
}
@ -101,11 +105,24 @@ namespace Api\Mail {
}
$fromMail = $mailConfig->getProperty('from');
$mailFooter = $mailConfig->getProperty('mail_footer');
$toMail = $this->getParam('to') ?? $fromMail;
$subject = $this->getParam('subject');
$replyTo = $this->getParam('replyTo');
$replyName = $this->getParam('replyName');
$body = $this->getParam('body');
$gpgFingerprint = $this->getParam("gpgFingerprint");
if ($this->getParam("async")) {
$sql = $this->user->getSQL();
$this->success = $sql->insert("MailQueue", ["from", "to", "subject", "body",
"replyTo", "replyName", "gpgFingerprint"])
->addRow($fromMail, $toMail, $subject, $body, $replyTo, $replyName, $gpgFingerprint)
->execute() !== false;
$this->lastError = $sql->getLastError();
return $this->success;
}
if (stripos($body, "<body") === false) {
$body = "<body>$body</body>";
@ -114,6 +131,14 @@ namespace Api\Mail {
$body = "<html>$body</html>";
}
if (!empty($mailFooter)) {
$email_signature = realpath(WEBROOT . DIRECTORY_SEPARATOR . $mailFooter);
if (is_file($email_signature)) {
$email_signature = file_get_contents($email_signature);
$body .= $email_signature;
}
}
try {
$mail = new PHPMailer;
$mail->IsSMTP();
@ -129,12 +154,36 @@ namespace Api\Mail {
$mail->Host = $mailConfig->getHost();
$mail->Port = $mailConfig->getPort();
$mail->SMTPAuth = true;
$mail->Timeout = 15;
$mail->Username = $mailConfig->getLogin();
$mail->Password = $mailConfig->getPassword();
$mail->SMTPSecure = 'tls';
$mail->CharSet = 'UTF-8';
$mail->msgHTML($body);
$mail->AltBody = strip_tags($body);
if ($gpgFingerprint) {
$encryptedHeaders = implode("\r\n", [
"Date: " . (new \DateTime())->format(DateTimeInterface::RFC2822),
"Content-Type: text/html",
"Content-Transfer-Encoding: quoted-printable"
]);
$mimeBody = $encryptedHeaders . "\r\n\r\n" . quoted_printable_encode($body);
$res = GpgKey::encrypt($mimeBody, $gpgFingerprint);
if ($res["success"]) {
$encryptedBody = $res["data"];
$mail->AltBody = '';
$mail->Body = '';
$mail->AllowEmpty = true;
$mail->ContentType = PHPMailer::CONTENT_TYPE_MULTIPART_ENCRYPTED;
$mail->addStringAttachment("Version: 1", null, PHPMailer::ENCODING_BASE64, "application/pgp-encrypted", "");
$mail->addStringAttachment($encryptedBody, "encrypted.asc", PHPMailer::ENCODING_7BIT, "application/octet-stream", "");
} else {
return $this->createError($res["error"]);
}
} else {
$mail->msgHTML($body);
$mail->AltBody = strip_tags($body);
}
$this->success = @$mail->Send();
if (!$this->success) {
@ -415,4 +464,101 @@ namespace Api\Mail {
return $this->success;
}
}
class SendQueue extends MailAPI {
public function __construct(User $user, bool $externalCall = false) {
parent::__construct($user, $externalCall, [
"debug" => new Parameter("debug", Parameter::TYPE_BOOLEAN, true, false)
]);
$this->isPublic = false;
}
public function execute($values = array()): bool {
if (!parent::execute($values)) {
return false;
}
$debug = $this->getParam("debug");
$startTime = time();
if ($debug) {
echo "Start of processing mail queue at $startTime" . PHP_EOL;
}
$sql = $this->user->getSQL();
$res = $sql->select("uid", "from", "to", "subject", "body",
"replyTo", "replyName", "gpgFingerprint", "retryCount")
->from("MailQueue")
->where(new Compare("retryCount", 0, ">"))
->where(new Compare("status", "waiting"))
->where(new Compare("nextTry", $sql->now(), "<="))
->execute();
$this->success = ($res !== false);
$this->lastError = $sql->getLastError();
if ($this->success && is_array($res)) {
if ($debug) {
echo "Found " . count($res) . " mails to send" . PHP_EOL;
}
$successfulMails = [];
foreach ($res as $row) {
if (time() - $startTime >= 45) {
$this->lastError = "Not able to process whole mail queue within 45 seconds, will continue on next time";
break;
}
$to = $row["to"];
$subject = $row["subject"];
if ($debug) {
echo "Sending subject=$subject to=$to" . PHP_EOL;
}
$mailId = intval($row["uid"]);
$retryCount = intval($row["retryCount"]);
$req = new Send($this->user);
$args = [
"to" => $to,
"subject" => $subject,
"body" => $row["body"],
"replyTo" => $row["replyTo"],
"replyName" => $row["replyName"],
"gpgFingerprint" => $row["gpgFingerprint"],
"async" => false
];
$success = $req->execute($args);
$error = $req->getLastError();
if (!$success) {
$delay = [0, 720, 360, 60, 30, 1];
$minutes = $delay[max(0, min(count($delay) - 1, $retryCount))];
$nextTry = (new \DateTime())->modify("+$minutes minute");
$sql->update("MailQueue")
->set("retryCount", $retryCount - 1)
->set("status", "error")
->set("errorMessage", $error)
->set("nextTry", $nextTry)
->where(new Compare("uid", $mailId))
->execute();
} else {
$successfulMails[] = $mailId;
}
}
$this->success = count($successfulMails) === count($res);
if (!empty($successfulMails)) {
$res = $sql->update("MailQueue")
->set("status", "success")
->where(new CondIn(new Column("uid"), $successfulMails))
->execute();
$this->success = $res !== false;
$this->lastError = $sql->getLastError();
}
}
return $this->success;
}
}
}

199
core/Api/NewsAPI.class.php Normal file

@ -0,0 +1,199 @@
<?php
namespace Api {
use Objects\User;
abstract class NewsAPI extends Request {
public function __construct(User $user, bool $externalCall = false, array $params = array()) {
parent::__construct($user, $externalCall, $params);
$this->loginRequired = true;
}
}
}
namespace Api\News {
use Api\NewsAPI;
use Api\Parameter\Parameter;
use Api\Parameter\StringType;
use Driver\SQL\Condition\Compare;
use Objects\User;
class Get extends NewsAPI {
public function __construct(User $user, bool $externalCall = false) {
parent::__construct($user, $externalCall, [
"since" => new Parameter("since", Parameter::TYPE_DATE_TIME, true, null),
"limit" => new Parameter("limit", Parameter::TYPE_INT, true, 10)
]);
}
public function execute($values = array()): bool {
if (!parent::execute($values)) {
return false;
}
$sql = $this->user->getSQL();
$query = $sql->select("News.uid", "title", "text", "publishedAt",
"User.uid as publisherId", "User.name as publisherName", "User.fullName as publisherFullName")
->from("News")
->innerJoin("User", "User.uid", "News.publishedBy")
->orderBy("publishedAt")
->descending();
$since = $this->getParam("since");
if ($since) {
$query->where(new Compare("publishedAt", $since, ">="));
}
$limit = $this->getParam("limit");
if ($limit < 1 || $limit > 30) {
return $this->createError("Limit must be in range 1-30");
} else {
$query->limit($limit);
}
$res = $query->execute();
$this->success = $res !== false;
$this->lastError = $sql->getLastError();
if ($this->success) {
$this->result["news"] = [];
foreach ($res as $row) {
$newsId = intval($row["uid"]);
$this->result["news"][$newsId] = [
"id" => $newsId,
"title" => $row["title"],
"text" => $row["text"],
"publishedAt" => $row["publishedAt"],
"publisher" => [
"id" => intval($row["publisherId"]),
"name" => $row["publisherName"],
"fullName" => $row["publisherFullName"]
]
];
}
}
return $this->success;
}
}
class Publish extends NewsAPI {
public function __construct(User $user, bool $externalCall = false) {
parent::__construct($user, $externalCall, [
"title" => new StringType("title", 128),
"text" => new StringType("text", 1024)
]);
}
public function execute($values = array()): bool {
if (!parent::execute($values)) {
return false;
}
$sql = $this->user->getSQL();
$title = $this->getParam("title");
$text = $this->getParam("text");
$res = $sql->insert("News", ["title", "text"])
->addRow($title, $text)
->returning("uid")
->execute();
$this->success = $res !== false;
$this->lastError = $sql->getLastError();
if ($this->success) {
$this->result["newsId"] = $sql->getLastInsertId();
}
return true;
}
}
class Delete extends NewsAPI {
public function __construct(User $user, bool $externalCall = false) {
parent::__construct($user, $externalCall, [
"id" => new Parameter("id", Parameter::TYPE_INT)
]);
}
public function execute($values = array()): bool {
if (!parent::execute($values)) {
return false;
}
$sql = $this->user->getSQL();
$id = $this->getParam("id");
$res = $sql->select("publishedBy")
->from("News")
->where(new Compare("uid", $id))
->execute();
$this->success = ($res !== false);
$this->lastError = $sql->getLastError();
if (!$this->success) {
return false;
} else if (empty($res) || !is_array($res)) {
return $this->createError("News Post not found");
} else if (intval($res[0]["publishedBy"]) !== $this->user->getId() && !$this->user->hasGroup(USER_GROUP_ADMIN)) {
return $this->createError("You do not have permissions to delete news post of other users.");
}
$res = $sql->delete("News")
->where(new Compare("uid", $id))
->execute();
$this->success = $res !== false;
$this->lastError = $sql->getLastError();
return $this->success;
}
}
class Edit extends NewsAPI {
public function __construct(User $user, bool $externalCall = false) {
parent::__construct($user, $externalCall, [
"id" => new Parameter("id", Parameter::TYPE_INT),
"title" => new StringType("title", 128),
"text" => new StringType("text", 1024)
]);
}
public function execute($values = array()): bool {
if (!parent::execute($values)) {
return false;
}
$sql = $this->user->getSQL();
$id = $this->getParam("id");
$text = $this->getParam("text");
$title = $this->getParam("title");
$res = $sql->select("publishedBy")
->from("News")
->where(new Compare("uid", $id))
->execute();
$this->success = ($res !== false);
$this->lastError = $sql->getLastError();
if (!$this->success) {
return false;
} else if (empty($res) || !is_array($res)) {
return $this->createError("News Post not found");
} else if (intval($res[0]["publishedBy"]) !== $this->user->getId() && !$this->user->hasGroup(USER_GROUP_ADMIN)) {
return $this->createError("You do not have permissions to edit news post of other users.");
}
$res = $sql->update("News")
->set("title", $title)
->set("text", $text)
->where(new Compare("uid", $id))
->execute();
$this->success = $res !== false;
$this->lastError = $sql->getLastError();
return $this->success;
}
}
}

@ -17,13 +17,19 @@ class Parameter {
// only internal access
const TYPE_RAW = 8;
// only json will work here i guess
// only json will work here I guess
// nope. also name[]=value
const TYPE_ARRAY = 9;
const TYPE_MIXED = 10;
const names = array('Integer', 'Float', 'Boolean', 'String', 'Date', 'Time', 'DateTime', 'E-Mail', 'Raw', 'Array', 'Mixed');
const DATE_FORMAT = "Y-m-d";
const TIME_FORMAT = "H:i:s";
const DATE_TIME_FORMAT = self::DATE_FORMAT . " " . self::TIME_FORMAT;
private $defaultValue;
public string $name;
public $value;
public bool $optional;
@ -33,11 +39,44 @@ class Parameter {
public function __construct(string $name, int $type, bool $optional = FALSE, $defaultValue = NULL) {
$this->name = $name;
$this->optional = $optional;
$this->defaultValue = $defaultValue;
$this->value = $defaultValue;
$this->type = $type;
$this->typeName = $this->getTypeName();
}
public function reset() {
$this->value = $this->defaultValue;
}
public function getSwaggerTypeName(): string {
$typeName = strtolower(($this->type >= 0 && $this->type < count(Parameter::names)) ? Parameter::names[$this->type] : "invalid");
if ($typeName === "mixed" || $typeName === "raw") {
return "object";
}
if (!in_array($typeName, ["array", "boolean", "integer", "number", "object", "string"])) {
return "string";
}
return $typeName;
}
public function getSwaggerFormat(): ?string {
switch ($this->type) {
case self::TYPE_DATE:
return self::DATE_FORMAT;
case self::TYPE_TIME:
return self::TIME_FORMAT;
case self::TYPE_DATE_TIME:
return self::DATE_TIME_FORMAT;
case self::TYPE_EMAIL:
return "email";
default:
return null;
}
}
public function getTypeName(): string {
return ($this->type >= 0 && $this->type < count(Parameter::names)) ? Parameter::names[$this->type] : "INVALID";
}
@ -65,11 +104,11 @@ class Parameter {
return Parameter::TYPE_BOOLEAN;
else if(is_a($value, 'DateTime'))
return Parameter::TYPE_DATE_TIME;
else if(($d = DateTime::createFromFormat('Y-m-d', $value)) && $d->format('Y-m-d') === $value)
else if(($d = DateTime::createFromFormat(self::DATE_FORMAT, $value)) && $d->format(self::DATE_FORMAT) === $value)
return Parameter::TYPE_DATE;
else if(($d = DateTime::createFromFormat('H:i:s', $value)) && $d->format('H:i:s') === $value)
else if(($d = DateTime::createFromFormat(self::TIME_FORMAT, $value)) && $d->format(self::TIME_FORMAT) === $value)
return Parameter::TYPE_TIME;
else if(($d = DateTime::createFromFormat('Y-m-d H:i:s', $value)) && $d->format('Y-m-d H:i:s') === $value)
else if(($d = DateTime::createFromFormat(self::DATE_TIME_FORMAT, $value)) && $d->format(self::DATE_TIME_FORMAT) === $value)
return Parameter::TYPE_DATE_TIME;
else if (filter_var($value, FILTER_VALIDATE_EMAIL))
return Parameter::TYPE_EMAIL;
@ -110,8 +149,8 @@ class Parameter {
return true;
}
$d = DateTime::createFromFormat('Y-m-d', $value);
if($d && $d->format('Y-m-d') === $value) {
$d = DateTime::createFromFormat(self::DATE_FORMAT, $value);
if($d && $d->format(self::DATE_FORMAT) === $value) {
$this->value = $d;
return true;
}
@ -123,8 +162,8 @@ class Parameter {
return true;
}
$d = DateTime::createFromFormat('H:i:s', $value);
if($d && $d->format('H:i:s') === $value) {
$d = DateTime::createFromFormat(self::TIME_FORMAT, $value);
if($d && $d->format(self::TIME_FORMAT) === $value) {
$this->value = $d;
return true;
}
@ -135,8 +174,8 @@ class Parameter {
$this->value = $value;
return true;
} else {
$d = DateTime::createFromFormat('Y-m-d H:i:s', $value);
if($d && $d->format('Y-m-d H:i:s') === $value) {
$d = DateTime::createFromFormat(self::DATE_TIME_FORMAT, $value);
if($d && $d->format(self::DATE_TIME_FORMAT) === $value) {
$this->value = $d;
return true;
}

@ -2,7 +2,9 @@
namespace Api;
use Api\Parameter\Parameter;
use Objects\User;
use PhpMqtt\Client\MqttClient;
class Request {
@ -45,6 +47,14 @@ class Request {
}
}
public function getDefaultParams(): array {
return $this->defaultParams;
}
public function isDisabled(): bool {
return $this->isDisabled;
}
protected function allowMethod($method) {
$availableMethods = ["GET", "HEAD", "POST", "PUT", "DELETE", "PATCH", "TRACE", "CONNECT"];
if (in_array($method, $availableMethods) && !in_array($method, $this->allowedMethods)) {
@ -70,6 +80,7 @@ class Request {
return $this->createError("Missing parameter: $name");
}
$param->reset();
if (!is_null($value) && !$isEmpty) {
if (!$param->parseParam($value)) {
$value = print_r($value, true);
@ -97,6 +108,7 @@ class Request {
}
public function execute($values = array()): bool {
$this->params = array_merge([], $this->defaultParams);
$this->success = false;
$this->result = array();
@ -165,6 +177,13 @@ class Request {
$this->lastError = 'You are not logged in.';
http_response_code(401);
return false;
} else if ($this->user->isLoggedIn()) {
$tfaToken = $this->user->getTwoFactorToken();
if ($tfaToken && $tfaToken->isConfirmed() && !$tfaToken->isAuthenticated()) {
$this->lastError = '2FA-Authorization is required';
http_response_code(401);
return false;
}
}
}
@ -172,7 +191,8 @@ class Request {
if ($this->csrfTokenRequired && $this->user->isLoggedIn()) {
// csrf token required + external call
// if it's not a call with API_KEY, check for csrf_token
if (!isset($values["csrf_token"]) || strcmp($values["csrf_token"], $this->user->getSession()->getCsrfToken()) !== 0) {
$csrfToken = $values["csrf_token"] ?? $_SERVER["HTTP_XSRF_TOKEN"] ?? null;
if (!$csrfToken || strcmp($csrfToken, $this->user->getSession()->getCsrfToken()) !== 0) {
$this->lastError = "CSRF-Token mismatch";
http_response_code(403);
return false;
@ -223,6 +243,10 @@ class Request {
return (isset($obj[$name]) ? $obj[$name]->value : NULL);
}
public function isMethodAllowed(string $method): bool {
return in_array($method, $this->allowedMethods);
}
public function isPublic(): bool {
return $this->isPublic;
}
@ -268,6 +292,14 @@ class Request {
flush();
}
protected function disableCache() {
header("Last-Modified: " . (new \DateTime())->format("D, d M Y H:i:s T"));
header("Expires: Sat, 26 Jul 1997 05:00:00 GMT");
header("Cache-Control: no-store, no-cache, must-revalidate, max-age=0");
header("Cache-Control: post-check=0, pre-check=0", false);
header("Pragma: no-cache");
}
protected function setupSSE() {
$this->user->getSQL()->close();
$this->user->sendCookies();
@ -276,11 +308,33 @@ class Request {
header('Content-Type: text/event-stream');
header('Connection: keep-alive');
header('X-Accel-Buffering: no');
header('Cache-Control: no-cache');
$this->disableCache();
$this->disableOutputBuffer();
}
/**
* @throws \PhpMqtt\Client\Exceptions\ProtocolViolationException
* @throws \PhpMqtt\Client\Exceptions\DataTransferException
* @throws \PhpMqtt\Client\Exceptions\MqttClientException
*/
protected function startMqttSSE(MqttClient $mqtt, callable $onPing) {
$lastPing = 0;
$mqtt->registerLoopEventHandler(function(MqttClient $mqtt, $elapsed) use (&$lastPing, $onPing) {
if ($elapsed - $lastPing >= 5) {
$onPing();
$lastPing = $elapsed;
}
if (connection_status() !== 0) {
$mqtt->interrupt();
}
});
$mqtt->loop();
$this->lastError = "MQTT Loop disconnected";
$mqtt->disconnect();
}
protected function processImageUpload(string $uploadDir, array $allowedExtensions = ["jpg","jpeg","png","gif"], $transformCallback = null) {
if (empty($_FILES)) {
return $this->createError("You need to upload an image.");

198
core/Api/Swagger.class.php Normal file

@ -0,0 +1,198 @@
<?php
namespace Api;
use Api\Parameter\StringType;
use Objects\User;
class Swagger extends Request {
public function __construct(User $user, bool $externalCall = false) {
parent::__construct($user, $externalCall, []);
$this->csrfTokenRequired = false;
}
public function execute($values = array()): bool {
if (!parent::execute($values)) {
return false;
}
header("Content-Type: application/x-yaml");
header("Access-Control-Allow-Origin: *");
die($this->getDocumentation());
}
private function getApiEndpoints(): array {
// first load all direct classes
$classes = [];
$basePath = realpath(WEBROOT . "/core/Api/");
foreach (scandir($basePath) as $fileName) {
$fullPath = $basePath . "/" . $fileName;
if (is_file($fullPath) && endsWith($fileName, ".class.php")) {
require_once $fullPath;
$apiName = explode(".", $fileName)[0];
$className = "\\Api\\$apiName";
if (!class_exists($className)) {
var_dump("Class not exist: $className");
continue;
}
$reflection = new \ReflectionClass($className);
if (!$reflection->isSubclassOf(Request::class) || $reflection->isAbstract()) {
continue;
}
$endpoint = "/" . strtolower($apiName);
$classes[$endpoint] = $reflection;
}
}
// then load all inheriting classes
foreach (get_declared_classes() as $declaredClass) {
$reflectionClass = new \ReflectionClass($declaredClass);
if (!$reflectionClass->isAbstract() && $reflectionClass->isSubclassOf(Request::class)) {
$inheritingClass = $reflectionClass->getParentClass();
if ($inheritingClass->isAbstract() && endsWith($inheritingClass->getShortName(), "API")) {
$endpoint = strtolower(substr($inheritingClass->getShortName(), 0, -3));
$endpoint = "/$endpoint/" . lcfirst($reflectionClass->getShortName());
$classes[$endpoint] = $reflectionClass;
}
}
}
return $classes;
}
private function fetchPermissions(): array {
$req = new Permission\Fetch($this->user);
$this->success = $req->execute();
$permissions = [];
foreach( $req->getResult()["permissions"] as $permission) {
$permissions["/" . strtolower($permission["method"])] = $permission["groups"];
}
return $permissions;
}
private function canView(array $requiredGroups, Request $request): bool {
if (!$request->isPublic()) {
return false;
}
if (($request->loginRequired() || !empty($requiredGroups)) && !$this->user->isLoggedIn()) {
return false;
}
// special case: hardcoded permission
if ($request instanceof Permission\Save && (!$this->user->isLoggedIn() || !$this->user->hasGroup(USER_GROUP_ADMIN))) {
return false;
}
if (!empty($requiredGroups)) {
return !empty(array_intersect($requiredGroups, $this->user->getGroups()));
}
return true;
}
private function getDocumentation(): string {
$settings = $this->user->getConfiguration()->getSettings();
$siteName = $settings->getSiteName();
$domain = parse_url($settings->getBaseUrl(), PHP_URL_HOST);
$permissions = $this->fetchPermissions();
$definitions = [];
$paths = [];
foreach (self::getApiEndpoints() as $endpoint => $apiClass) {
$body = null;
$requiredProperties = [];
$apiObject = $apiClass->newInstance($this->user);
if (!$this->canView($permissions[strtolower($endpoint)] ?? [], $apiObject)) {
continue;
}
$parameters = $apiObject->getDefaultParams();
if (!empty($parameters)) {
$body = [];
foreach ($apiObject->getDefaultParams() as $param) {
$body[$param->name] = [
"type" => $param->getSwaggerTypeName(),
"default" => $param->value
];
if ($param instanceof StringType && $param->maxLength > 0) {
$body[$param->name]["maxLength"] = $param->maxLength;
}
if ($body[$param->name]["type"] === "string" && ($format = $param->getSwaggerFormat())) {
$body[$param->name]["format"] = $format;
}
if (!$param->optional) {
$requiredProperties[] = $param->name;
}
}
$bodyName = $apiClass->getShortName() . "Body";
$definitions[$bodyName] = [
"description" => "Body for $endpoint",
"properties" => $body
];
if (!empty($requiredProperties)) {
$definitions[$bodyName]["required"] = $requiredProperties;
}
}
$endPointDefinition = [
"post" => [
"produces" => ["application/json"],
"responses" => [
"200" => ["description" => ""],
"401" => ["description" => "Login or 2FA Authorization is required"],
]
]
];
if ($apiObject->isDisabled()) {
$endPointDefinition["post"]["deprecated"] = true;
}
if ($body) {
$endPointDefinition["post"]["consumes"] = ["application/json"];
$endPointDefinition["post"]["parameters"] = [[
"in" => "body",
"name" => "body",
"required" => !empty($requiredProperties),
"schema" => ["\$ref" => "#/definitions/" . $apiClass->getShortName() . "Body"]
]];
} else if ($apiObject->isMethodAllowed("GET")) {
$endPointDefinition["get"] = $endPointDefinition["post"];
unset($endPointDefinition["post"]);
}
$paths[$endpoint] = $endPointDefinition;
}
$yamlData = [
"swagger" => "2.0",
"info" => [
"description" => "This is the Backend API-Description of $siteName",
"version" => WEBBASE_VERSION,
"title" => $siteName,
"contact" => [ "email" => "webmaster@$domain" ],
],
"host" => $domain,
"basePath" => "/api",
"schemes" => ["https"],
"paths" => $paths,
"definitions" => $definitions
];
return yaml_emit($yamlData);
}
}

409
core/Api/TfaAPI.class.php Normal file

@ -0,0 +1,409 @@
<?php
namespace Api {
use Objects\TwoFactor\AuthenticationData;
use Objects\TwoFactor\KeyBasedTwoFactorToken;
use Objects\User;
abstract class TfaAPI extends Request {
private bool $userVerficiationRequired;
public function __construct(User $user, bool $externalCall = false, array $params = array()) {
parent::__construct($user, $externalCall, $params);
$this->loginRequired = true;
$this->userVerficiationRequired = false;
}
protected function verifyAuthData(AuthenticationData $authData): bool {
$settings = $this->user->getConfiguration()->getSettings();
// $relyingParty = $settings->getSiteName();
$domain = parse_url($settings->getBaseUrl(), PHP_URL_HOST);
// $domain = "localhost";
if (!$authData->verifyIntegrity($domain)) {
return $this->createError("mismatched rpIDHash. expected: " . hash("sha256", $domain) . " got: " . bin2hex($authData->getHash()));
} else if (!$authData->isUserPresent()) {
return $this->createError("No user present");
} else if ($this->userVerficiationRequired && !$authData->isUserVerified()) {
return $this->createError("user was not verified on device (PIN/Biometric/...)");
} else if ($authData->hasExtensionData()) {
return $this->createError("No extensions supported");
}
return true;
}
protected function verifyClientDataJSON($jsonData, KeyBasedTwoFactorToken $token): bool {
$settings = $this->user->getConfiguration()->getSettings();
$expectedType = $token->isConfirmed() ? "webauthn.get" : "webauthn.create";
$type = $jsonData["type"] ?? "null";
if ($type !== $expectedType) {
return $this->createError("Invalid client data json type. Expected: '$expectedType', Got: '$type'");
} else if ($token->getData() !== base64url_decode($jsonData["challenge"] ?? "")) {
return $this->createError("Challenge does not match");
} else if (($jsonData["origin"] ?? null) !== $settings->getBaseURL()) {
$baseUrl = $settings->getBaseURL();
return $this->createError("Origin does not match. Expected: '$baseUrl', Got: '${$jsonData["origin"]}'");
}
return true;
}
}
}
namespace Api\TFA {
use Api\Parameter\StringType;
use Api\TfaAPI;
use Driver\SQL\Condition\Compare;
use Objects\TwoFactor\AttestationObject;
use Objects\TwoFactor\AuthenticationData;
use Objects\TwoFactor\KeyBasedTwoFactorToken;
use Objects\TwoFactor\TimeBasedTwoFactorToken;
use Objects\User;
// General
class Remove extends TfaAPI {
public function __construct(User $user, bool $externalCall = false) {
parent::__construct($user, $externalCall, [
"password" => new StringType("password", 0, true)
]);
}
public function execute($values = array()): bool {
if (!parent::execute($values)) {
return false;
}
$token = $this->user->getTwoFactorToken();
if (!$token) {
return $this->createError("You do not have an active 2FA-Token");
}
$sql = $this->user->getSQL();
$password = $this->getParam("password");
if ($password) {
$res = $sql->select("password")
->from("User")
->where(new Compare("uid", $this->user->getId()))
->execute();
$this->success = !empty($res);
$this->lastError = $sql->getLastError();
if (!$this->success) {
return false;
} else if (!password_verify($password, $res[0]["password"])) {
return $this->createError("Wrong password");
}
} else if ($token->isConfirmed()) {
// if the token is fully confirmed, require a password to remove it
return $this->createError("Missing parameter: password");
}
$res = $sql->delete("2FA")
->where(new Compare("uid", $token->getId()))
->execute();
$this->success = $res !== false;
$this->lastError = $sql->getLastError();
if ($this->success && $token->isConfirmed()) {
// send an email
$settings = $this->user->getConfiguration()->getSettings();
$req = new \Api\Template\Render($this->user);
$this->success = $req->execute([
"file" => "mail/2fa_remove.twig",
"parameters" => [
"username" => $this->user->getFullName() ?? $this->user->getUsername(),
"site_name" => $settings->getSiteName(),
"sender_mail" => $settings->getMailSender()
]
]);
if ($this->success) {
$body = $req->getResult()["html"];
$gpg = $this->user->getGPG();
$req = new \Api\Mail\Send($this->user);
$this->success = $req->execute([
"to" => $this->user->getEmail(),
"subject" => "[Security Lab] 2FA-Authentication removed",
"body" => $body,
"gpgFingerprint" => $gpg ? $gpg->getFingerprint() : null
]);
}
$this->lastError = $req->getLastError();
}
return $this->success;
}
}
// TOTP
class GenerateQR extends TfaAPI {
public function __construct(User $user, bool $externalCall = false) {
parent::__construct($user, $externalCall);
$this->csrfTokenRequired = false;
}
public function execute($values = array()): bool {
if (!parent::execute($values)) {
return false;
}
$twoFactorToken = $this->user->getTwoFactorToken();
if ($twoFactorToken && $twoFactorToken->isConfirmed()) {
return $this->createError("You already added a two factor token");
} else if (!($twoFactorToken instanceof TimeBasedTwoFactorToken)) {
$twoFactorToken = new TimeBasedTwoFactorToken(generateRandomString(32, "base32"));
$sql = $this->user->getSQL();
$this->success = $sql->insert("2FA", ["type", "data"])
->addRow("totp", $twoFactorToken->getData())
->returning("uid")
->execute() !== false;
$this->lastError = $sql->getLastError();
if ($this->success) {
$this->success = $sql->update("User")
->set("2fa_id", $sql->getLastInsertId())->where(new Compare("uid", $this->user->getId()))
->execute() !== false;
$this->lastError = $sql->getLastError();
}
if (!$this->success) {
return false;
}
}
header("Content-Type: image/png");
$this->disableCache();
die($twoFactorToken->generateQRCode($this->user));
}
}
class ConfirmTotp extends VerifyTotp {
public function __construct(User $user, bool $externalCall = false) {
parent::__construct($user, $externalCall);
$this->loginRequired = true;
}
public function execute($values = array()): bool {
if (!parent::execute($values)) {
return false;
}
$twoFactorToken = $this->user->getTwoFactorToken();
if ($twoFactorToken->isConfirmed()) {
return $this->createError("Your two factor token is already confirmed.");
}
$sql = $this->user->getSQL();
$this->success = $sql->update("2FA")
->set("confirmed", true)
->where(new Compare("uid", $twoFactorToken->getId()))
->execute() !== false;
$this->lastError = $sql->getLastError();
return $this->success;
}
}
class VerifyTotp extends TfaAPI {
public function __construct(User $user, bool $externalCall = false) {
parent::__construct($user, $externalCall, [
"code" => new StringType("code", 6)
]);
$this->loginRequired = false;
$this->csrfTokenRequired = false;
}
public function execute($values = array()): bool {
if (!parent::execute($values)) {
return false;
}
$session = $this->user->getSession();
if (!$session) {
return $this->createError("You are not logged in.");
}
$twoFactorToken = $this->user->getTwoFactorToken();
if (!$twoFactorToken) {
return $this->createError("You did not add a two factor token yet.");
} else if (!($twoFactorToken instanceof TimeBasedTwoFactorToken)) {
return $this->createError("Invalid 2FA-token endpoint");
}
$code = $this->getParam("code");
if (!$twoFactorToken->verify($code)) {
return $this->createError("Code does not match");
}
$twoFactorToken->authenticate();
return $this->success;
}
}
// Key
class RegisterKey extends TfaAPI {
public function __construct(User $user, bool $externalCall = false) {
parent::__construct($user, $externalCall, [
"clientDataJSON" => new StringType("clientDataJSON", 0, true, "{}"),
"attestationObject" => new StringType("attestationObject", 0, true, "")
]);
}
public function execute($values = array()): bool {
if (!parent::execute($values)) {
return false;
}
$clientDataJSON = json_decode($this->getParam("clientDataJSON"), true);
$attestationObjectRaw = base64_decode($this->getParam("attestationObject"));
$twoFactorToken = $this->user->getTwoFactorToken();
$settings = $this->user->getConfiguration()->getSettings();
$relyingParty = $settings->getSiteName();
$sql = $this->user->getSQL();
// TODO: for react development, localhost / HTTP_HOST is required, otherwise a DOMException is thrown
$domain = parse_url($settings->getBaseUrl(), PHP_URL_HOST);
// $domain = "localhost";
if (!$clientDataJSON || !$attestationObjectRaw) {
if ($twoFactorToken) {
if (!($twoFactorToken instanceof KeyBasedTwoFactorToken) || $twoFactorToken->isConfirmed()) {
return $this->createError("You already added a two factor token");
} else {
$challenge = base64_encode($twoFactorToken->getData());
}
} else {
$challenge = base64_encode(generateRandomString(32, "raw"));
$res = $sql->insert("2FA", ["type", "data"])
->addRow("fido", $challenge)
->returning("uid")
->execute();
$this->success = ($res !== false);
$this->lastError = $sql->getLastError();
if (!$this->success) {
return false;
}
$this->success = $sql->update("User")
->set("2fa_id", $sql->getLastInsertId())
->where(new Compare("uid", $this->user->getId()))
->execute() !== false;
$this->lastError = $sql->getLastError();
if (!$this->success) {
return false;
}
}
$this->result["data"] = [
"challenge" => $challenge,
"id" => $this->user->getId() . "@" . $domain, // <userId>@<domain>
"relyingParty" => [
"name" => $relyingParty,
"id" => $domain
],
];
} else {
if ($twoFactorToken === null) {
return $this->createError("Request a registration first.");
} else if (!($twoFactorToken instanceof KeyBasedTwoFactorToken)) {
return $this->createError("You already got a 2FA token");
}
if (!$this->verifyClientDataJSON($clientDataJSON, $twoFactorToken)) {
return false;
}
$attestationObject = new AttestationObject($attestationObjectRaw);
$authData = $attestationObject->getAuthData();
if (!$this->verifyAuthData($authData)) {
return false;
}
$publicKey = $authData->getPublicKey();
if ($publicKey->getUsedAlgorithm() !== -7) {
return $this->createError("Unsupported key type. Expected: -7");
}
$data = [
"credentialID" => base64_encode($authData->getCredentialID()),
"publicKey" => $publicKey->jsonSerialize()
];
$this->success = $sql->update("2FA")
->set("data", json_encode($data))
->set("confirmed", true)
->where(new Compare("uid", $twoFactorToken->getId()))
->execute() !== false;
$this->lastError = $sql->getLastError();
}
return $this->success;
}
}
class VerifyKey extends TfaAPI {
public function __construct(User $user, bool $externalCall = false) {
parent::__construct($user, $externalCall, [
"credentialID" => new StringType("credentialID"),
"clientDataJSON" => new StringType("clientDataJSON"),
"authData" => new StringType("authData"),
"signature" => new StringType("signature"),
]);
$this->loginRequired = false;
$this->csrfTokenRequired = false;
}
public function execute($values = array()): bool {
if (!parent::execute($values)) {
return false;
}
$session = $this->user->getSession();
if (!$session) {
return $this->createError("You are not logged in.");
}
$twoFactorToken = $this->user->getTwoFactorToken();
if (!$twoFactorToken) {
return $this->createError("You did not add a two factor token yet.");
} else if (!($twoFactorToken instanceof KeyBasedTwoFactorToken)) {
return $this->createError("Invalid 2FA-token endpoint");
} else if (!$twoFactorToken->isConfirmed()) {
return $this->createError("2FA-Key not confirmed yet");
}
$credentialID = base64url_decode($this->getParam("credentialID"));
if ($credentialID !== $twoFactorToken->getCredentialId()) {
return $this->createError("credential ID does not match");
}
$jsonData = $this->getParam("clientDataJSON");
if (!$this->verifyClientDataJSON(json_decode($jsonData, true), $twoFactorToken)) {
return false;
}
$authDataRaw = base64_decode($this->getParam("authData"));
$authData = new AuthenticationData($authDataRaw);
if (!$this->verifyAuthData($authData)) {
return false;
}
$clientDataHash = hash("sha256", $jsonData, true);
$signature = base64_decode($this->getParam("signature"));
$this->success = $twoFactorToken->verify($signature, $authDataRaw . $clientDataHash);
if ($this->success) {
$twoFactorToken->authenticate();
} else {
$this->lastError = "Verification failed";
}
return $this->success;
}
}
}

@ -67,11 +67,11 @@ namespace Api {
$this->checkPasswordRequirements($password, $confirmPassword);
}
protected function insertUser($username, $email, $password, $confirmed) {
protected function insertUser($username, $email, $password, $confirmed, $fullName = null) {
$sql = $this->user->getSQL();
$hash = $this->hashPassword($password);
$res = $sql->insert("User", array("name", "password", "email", "confirmed"))
->addRow($username, $hash, $email, $confirmed)
$res = $sql->insert("User", array("name", "password", "email", "confirmed", "fullName"))
->addRow($username, $hash, $email, $confirmed, $fullName)
->returning("uid")
->execute();
@ -93,10 +93,13 @@ namespace Api {
$sql = $this->user->getSQL();
$res = $sql->select("User.uid as userId", "User.name", "User.fullName", "User.email",
"User.registered_at", "User.confirmed", "User.last_online", "User.profilePicture",
"User.gpg_id", "GpgKey.confirmed as gpg_confirmed", "GpgKey.fingerprint as gpg_fingerprint",
"GpgKey.expires as gpg_expires", "GpgKey.algorithm as gpg_algorithm",
"Group.uid as groupId", "Group.name as groupName", "Group.color as groupColor")
->from("User")
->leftJoin("UserGroup", "User.uid", "UserGroup.user_id")
->leftJoin("Group", "Group.uid", "UserGroup.group_id")
->leftJoin("GpgKey", "GpgKey.uid", "User.gpg_id")
->where(new Compare("User.uid", $id))
->execute();
@ -152,6 +155,9 @@ namespace Api\User {
use Driver\SQL\Condition\CondNot;
use Driver\SQL\Expression\JsonArrayAgg;
use ImagickException;
use Objects\GpgKey;
use Objects\TwoFactor\KeyBasedTwoFactorToken;
use Objects\TwoFactor\TwoFactorToken;
use Objects\User;
class Create extends UserAPI {
@ -350,6 +356,11 @@ namespace Api\User {
return $this->createError("User not found");
} else {
$gpgFingerprint = null;
if ($user[0]["gpg_id"] && $sql->parseBool($user[0]["gpg_confirmed"])) {
$gpgFingerprint = $user[0]["gpg_fingerprint"];
}
$queriedUser = array(
"uid" => $userId,
"name" => $user[0]["name"],
@ -360,6 +371,7 @@ namespace Api\User {
"profilePicture" => $user[0]["profilePicture"],
"confirmed" => $sql->parseBool($user["0"]["confirmed"]),
"groups" => array(),
"gpgFingerprint" => $gpgFingerprint,
);
foreach($user as $row) {
@ -371,10 +383,9 @@ namespace Api\User {
}
}
// either we are querying own info or we are internal employees
// as internal employees can add arbitrary users to projects
$canView = ($userId === $this->user->getId() ||
$this->user->hasGroup(USER_GROUP_ADMIN) ||
// either we are querying own info or we are support / admin
$canView = ($userId === $this->user->getId()) ||
($this->user->hasGroup(USER_GROUP_ADMIN) ||
$this->user->hasGroup(USER_GROUP_SUPPORT));
// full info only when we have administrative privileges, or we are querying ourselves
@ -382,23 +393,17 @@ namespace Api\User {
($this->user->hasGroup(USER_GROUP_ADMIN) || $this->user->hasGroup(USER_GROUP_SUPPORT));
if (!$canView) {
$res = $sql->select(new JsonArrayAgg(new Column("projectId"), "projectIds"))
->from("ProjectMember")
->where(new Compare("userId", $this->user->getId()), new Compare("userId", $userId))
->groupBy("projectId")
->execute();
// check if user posted something publicly
$res = $sql->select(new JsonArrayAgg(new Column("publishedBy"), "publisherIds"))
->from("News")
->execute();
$this->success = ($res !== false);
$this->lastError = $sql->getLastError();
if (!$this->success ) {
return false;
} else if (is_array($res)) {
foreach ($res as $row) {
if (count(json_decode($row["projectIds"])) > 1) {
$canView = true;
break;
}
}
} else {
$canView = in_array($userId, json_decode($res[0]["publisherIds"], true));
}
}
@ -598,6 +603,7 @@ namespace Api\User {
} else if (!$this->updateUser($result["user"]["uid"], $password)) {
return false;
} else {
// Invalidate token
$this->user->getSQL()
->update("UserToken")
@ -671,7 +677,7 @@ namespace Api\User {
parent::__construct($user, $externalCall, array(
'username' => new StringType('username'),
'password' => new StringType('password'),
'stayLoggedIn' => new Parameter('stayLoggedIn', Parameter::TYPE_BOOLEAN, true, true)
'stayLoggedIn' => new Parameter('stayLoggedIn', Parameter::TYPE_BOOLEAN, true, false)
));
$this->forbidMethod("GET");
}
@ -701,9 +707,11 @@ namespace Api\User {
$stayLoggedIn = $this->getParam('stayLoggedIn');
$sql = $this->user->getSQL();
$res = $sql->select("User.uid", "User.password", "User.confirmed")
$res = $sql->select("User.uid", "User.password", "User.confirmed",
"User.2fa_id", "2FA.type as 2fa_type", "2FA.confirmed as 2fa_confirmed", "2FA.data as 2fa_data")
->from("User")
->where(new Compare("User.name", $username), new Compare("User.email", $username))
->leftJoin("2FA", "2FA.uid", "User.2fa_id")
->limit(1)
->execute();
@ -717,6 +725,7 @@ namespace Api\User {
$row = $res[0];
$uid = $row['uid'];
$confirmed = $sql->parseBool($row["confirmed"]);
$token = $row["2fa_id"] ? TwoFactorToken::newInstance($row["2fa_type"], $row["2fa_data"], $row["2fa_id"], $sql->parseBool($row["2fa_confirmed"])) : null;
if (password_verify($password, $row['password'])) {
if (!$confirmed) {
$this->result["emailConfirmed"] = false;
@ -727,6 +736,14 @@ namespace Api\User {
$this->result["loggedIn"] = true;
$this->result["logoutIn"] = $this->user->getSession()->getExpiresSeconds();
$this->result["csrf_token"] = $this->user->getSession()->getCsrfToken();
if ($token && $token->isConfirmed()) {
$this->result["2fa"] = ["type" => $token->getType()];
if ($token instanceof KeyBasedTwoFactorToken) {
$challenge = base64_encode(generateRandomString(32, "raw"));
$this->result["2fa"]["challenge"] = $challenge;
$_SESSION["challenge"] = $challenge;
}
}
$this->success = true;
}
} else {
@ -743,8 +760,9 @@ namespace Api\User {
public function __construct($user, $externalCall = false) {
parent::__construct($user, $externalCall);
$this->loginRequired = true;
$this->loginRequired = false;
$this->apiKeyAllowed = false;
$this->forbidMethod("GET");
}
public function execute($values = array()): bool {
@ -752,6 +770,10 @@ namespace Api\User {
return false;
}
if (!$this->user->isLoggedIn()) {
return $this->createError("You are not logged in.");
}
$this->success = $this->user->logout();
$this->lastError = $this->user->getSQL()->getLastError();
return $this->success;
@ -807,7 +829,6 @@ namespace Api\User {
$email = $this->getParam('email');
$password = $this->getParam("password");
$confirmPassword = $this->getParam("confirmPassword");
if (!$this->userExists($username, $email)) {
return false;
}
@ -816,16 +837,17 @@ namespace Api\User {
return false;
}
$this->userId = $this->insertUser($username, $email, $password, false);
$fullName = substr($email, 0, strrpos($email, "@"));
$fullName = implode(" ", array_map(function ($part) {
return ucfirst(strtolower($part));
}, explode(".", $fullName))
);
$this->userId = $this->insertUser($username, $email, $password, false, $fullName);
if (!$this->success) {
return false;
}
// add internal group
$this->user->getSQL()->insert("UserGroup", ["user_id", "group_id"])
->addRow($this->userId, USER_GROUP_INTERNAL)
->execute();
$validHours = 48;
$this->token = generateRandomString(36);
if ($this->insertToken($this->userId, $this->token, "email_confirm", $validHours)) {
@ -924,7 +946,7 @@ namespace Api\User {
class Edit extends UserAPI {
public function __construct(User $user, bool $externalCall) {
public function __construct(User $user, bool $externalCall = false) {
parent::__construct($user, $externalCall, array(
'id' => new Parameter('id', Parameter::TYPE_INT),
'username' => new StringType('username', 32, true, NULL),
@ -1032,7 +1054,7 @@ namespace Api\User {
class Delete extends UserAPI {
public function __construct(User $user, bool $externalCall) {
public function __construct(User $user, bool $externalCall = false) {
parent::__construct($user, $externalCall, array(
'id' => new Parameter('id', Parameter::TYPE_INT)
));
@ -1055,6 +1077,7 @@ namespace Api\User {
if (empty($user)) {
return $this->createError("User not found");
} else {
$sql = $this->user->getSQL();
$res = $sql->delete("User")->where(new Compare("uid", $id))->execute();
$this->success = ($res !== FALSE);
@ -1129,11 +1152,18 @@ namespace Api\User {
if ($this->success) {
$messageBody = $req->getResult()["html"];
$gpgFingerprint = null;
if ($user["gpg_id"] && $user["gpg_confirmed"]) {
$gpgFingerprint = $user["gpg_fingerprint"];
}
$request = new \Api\Mail\Send($this->user);
$this->success = $request->execute(array(
"to" => $email,
"subject" => "[$siteName] Password Reset",
"body" => $messageBody
"body" => $messageBody,
"gpgFingerprint" => $gpgFingerprint
));
$this->lastError = $request->getLastError();
}
@ -1144,8 +1174,10 @@ namespace Api\User {
private function findUser($email): ?array {
$sql = $this->user->getSQL();
$res = $sql->select("User.uid", "User.name")
$res = $sql->select("User.uid", "User.name",
"User.gpg_id", "GpgKey.confirmed as gpg_confirmed", "GpgKey.fingerprint as gpg_fingerprint")
->from("User")
->leftJoin("GpgKey", "GpgKey.uid", "User.gpg_id")
->where(new Compare("User.email", $email))
->where(new CondBool("User.confirmed"))
->execute();
@ -1399,6 +1431,321 @@ namespace Api\User {
}
}
class ImportGPG extends UserAPI {
public function __construct(User $user, bool $externalCall = false) {
parent::__construct($user, $externalCall, array(
"pubkey" => new StringType("pubkey")
));
$this->loginRequired = true;
$this->forbidMethod("GET");
}
private function testKey(string $keyString) {
$res = GpgKey::getKeyInfo($keyString);
if (!$res["success"]) {
return $this->createError($res["error"]);
}
$keyData = $res["data"];
$keyType = $keyData["type"];
$expires = $keyData["expires"];
if ($keyType === "sec#") {
return self::createError("ATTENTION! It seems like you've imported a PGP PRIVATE KEY instead of a public key.
It is recommended to immediately revoke your private key and create a new key pair.");
} else if ($keyType !== "pub") {
return self::createError("Unknown key type: $keyType");
} else if (isInPast($expires)) {
return self::createError("It seems like the gpg key is already expired.");
} else {
return $keyData;
}
}
public function execute($values = array()): bool {
if (!parent::execute($values)) {
return false;
}
$gpgKey = $this->user->getGPG();
if ($gpgKey) {
return $this->createError("You already added a GPG key to your account.");
}
// fix key first, enforce a newline after
$keyString = $this->getParam("pubkey");
$keyString = preg_replace("/(-{2,})\n([^\n])/", "$1\n\n$2", $keyString);
$keyData = $this->testKey($keyString);
if ($keyData === false) {
return false;
}
$res = GpgKey::importKey($keyString);
if (!$res["success"]) {
return $this->createError($res["error"]);
}
$sql = $this->user->getSQL();
$res = $sql->insert("GpgKey", ["fingerprint", "algorithm", "expires"])
->addRow($keyData["fingerprint"], $keyData["algorithm"], $keyData["expires"])
->returning("uid")
->execute();
$this->success = ($res !== false);
$this->lastError = $sql->getLastError();
if (!$this->success) {
return false;
}
$gpgKeyId = $sql->getLastInsertId();
$res = $sql->update("User")
->set("gpg_id", $gpgKeyId)
->where(new Compare("uid", $this->user->getId()))
->execute();
$this->success = ($res !== false);
$this->lastError = $sql->getLastError();
if (!$this->success) {
return false;
}
$token = generateRandomString(36);
$res = $sql->insert("UserToken", ["user_id", "token", "token_type", "valid_until"])
->addRow($this->user->getId(), $token, "gpg_confirm", (new DateTime())->modify("+1 hour"))
->execute();
$this->success = ($res !== false);
$this->lastError = $sql->getLastError();
if (!$this->success) {
return false;
}
$name = htmlspecialchars($this->user->getFullName());
if (!$name) {
$name = htmlspecialchars($this->user->getUsername());
}
$settings = $this->user->getConfiguration()->getSettings();
$baseUrl = htmlspecialchars($settings->getBaseUrl());
$token = htmlspecialchars(urlencode($token));
$mailBody = "Hello $name,<br><br>" .
"you imported a GPG public key for end-to-end encrypted mail communication. " .
"To confirm the key and verify, you own the corresponding private key, please click on the following link. " .
"The link is active for one hour.<br><br>" .
"<a href='$baseUrl/confirmGPG?token=$token'>$baseUrl/settings?confirmGPG&token=$token</a><br>
Best Regards<br>
ilum:e Security Lab";
$sendMail = new \Api\Mail\Send($this->user);
$this->success = $sendMail->execute(array(
"to" => $this->user->getEmail(),
"subject" => "Security Lab - Confirm GPG-Key",
"body" => $mailBody,
"gpgFingerprint" => $keyData["fingerprint"]
));
$this->lastError = $sendMail->getLastError();
if ($this->success) {
$this->result["gpg"] = array(
"fingerprint" => $keyData["fingerprint"],
"confirmed" => false,
"algorithm" => $keyData["algorithm"],
"expires" => $keyData["expires"]->getTimestamp()
);
}
return $this->success;
}
}
class RemoveGPG extends UserAPI {
public function __construct(User $user, bool $externalCall = false) {
parent::__construct($user, $externalCall, array(
"password" => new StringType("password")
));
$this->loginRequired = true;
$this->forbidMethod("GET");
}
public function execute($values = array()): bool {
if (!parent::execute($values)) {
return false;
}
$gpgKey = $this->user->getGPG();
if (!$gpgKey) {
return $this->createError("You have not added a GPG public key to your account yet.");
}
$sql = $this->user->getSQL();
$res = $sql->select("password")
->from("User")
->where(new Compare("User.uid", $this->user->getId()))
->execute();
$this->success = ($res !== false);
$this->lastError = $sql->getLastError();
if ($this->success && is_array($res)) {
$hash = $res[0]["password"];
$password = $this->getParam("password");
if (!password_verify($password, $hash)) {
return $this->createError("Incorrect password.");
} else {
$res = $sql->delete("GpgKey")
->where(new Compare("uid",
$sql->select("User.gpg_id")
->from("User")
->where(new Compare("User.uid", $this->user->getId()))
))->execute();
$this->success = ($res !== false);
$this->lastError = $sql->getLastError();
}
}
return $this->success;
}
}
class ConfirmGPG extends UserAPI {
public function __construct(User $user, bool $externalCall = false) {
parent::__construct($user, $externalCall, [
"token" => new StringType("token", 36)
]);
$this->loginRequired = true;
}
public function execute($values = array()): bool {
if (!parent::execute($values)) {
return false;
}
$gpgKey = $this->user->getGPG();
if (!$gpgKey) {
return $this->createError("You have not added a GPG key yet.");
} else if ($gpgKey->isConfirmed()) {
return $this->createError("Your GPG key is already confirmed");
}
$token = $this->getParam("token");
$sql = $this->user->getSQL();
$res = $sql->select($sql->count())
->from("UserToken")
->where(new Compare("token", $token))
->where(new Compare("valid_until", $sql->now(), ">="))
->where(new Compare("user_id", $this->user->getId()))
->where(new Compare("token_type", "gpg_confirm"))
->where(new CondNot(new CondBool("used")))
->execute();
$this->success = ($res !== false);
$this->lastError = $sql->getLastError();
if ($this->success && is_array($res)) {
if ($res[0]["count"] === 0) {
return $this->createError("Invalid token");
} else {
$res = $sql->update("GpgKey")
->set("confirmed", 1)
->where(new Compare("uid", $gpgKey->getId()))
->execute();
$this->success = ($res !== false);
$this->lastError = $sql->getLastError();
if (!$this->success) {
return false;
}
$res = $sql->update("UserToken")
->set("used", 1)
->where(new Compare("token", $token))
->execute();
$this->success = ($res !== false);
$this->lastError = $sql->getLastError();
}
}
return $this->success;
}
}
class DownloadGPG extends UserAPI {
public function __construct(User $user, bool $externalCall = false) {
parent::__construct($user, $externalCall, array(
"id" => new Parameter("id", Parameter::TYPE_INT, true, null),
"format" => new StringType("format", 16, true, "ascii")
));
$this->loginRequired = true;
$this->csrfTokenRequired = false;
}
public function execute($values = array()): bool {
if (!parent::execute($values)) {
return false;
}
$allowedFormats = ["json", "ascii", "gpg"];
$format = $this->getParam("format");
if (!in_array($format, $allowedFormats)) {
return $this->getParam("Invalid requested format. Allowed formats: " . implode(",", $allowedFormats));
}
$userId = $this->getParam("id");
if ($userId === null || $userId == $this->user->getId()) {
$gpgKey = $this->user->getGPG();
if (!$gpgKey) {
return $this->createError("You did not add a gpg key yet.");
}
$email = $this->user->getEmail();
$gpgFingerprint = $gpgKey->getFingerprint();
} else {
$req = new Get($this->user);
$this->success = $req->execute(["id" => $userId]);
$this->lastError = $req->getLastError();
if (!$this->success) {
return false;
}
$res = $req->getResult()["user"];
$email = $res["email"];
$gpgFingerprint = $res["gpgFingerprint"];
if (!$gpgFingerprint) {
return $this->createError("This user has not added a gpg key yet");
}
}
$res = GpgKey::export($gpgFingerprint, $format !== "gpg");
if (!$res["success"]) {
return $this->createError($res["error"]);
}
$key = $res["data"];
if ($format === "json") {
$this->result["key"] = $key;
return true;
} else if ($format === "ascii") {
$contentType = "application/pgp-keys";
$ext = "asc";
} else if ($format === "gpg") {
$contentType = "application/octet-stream";
$ext = "gpg";
} else {
die("Invalid format");
}
$fileName = "$email.$ext";
header("Content-Type: $contentType");
header("Content-Length: " . strlen($key));
header("Content-Disposition: attachment; filename=\"$fileName\"");
die($key);
}
}
class UploadPicture extends UserAPI {
public function __construct(User $user, bool $externalCall = false) {
parent::__construct($user, $externalCall, [

@ -2,8 +2,12 @@
namespace Api {
abstract class VisitorsAPI extends Request {
use Objects\User;
abstract class VisitorsAPI extends Request {
public function __construct(User $user, bool $externalCall = false, array $params = []) {
parent::__construct($user, $externalCall, $params);
}
}
}

@ -14,7 +14,7 @@ class Configuration {
$this->settings = Settings::loadDefaults();
$class = \Configuration\Database::class;
$path = getClassPath($class, true);
$path = getClassPath($class, ".class");
if (file_exists($path) && is_readable($path)) {
include_once $path;
if (class_exists($class)) {
@ -53,7 +53,7 @@ class Configuration {
} else if ($data instanceof ConnectionData) {
$superClass = get_class($data);
$host = addslashes($data->getHost());
$port = intval($data->getPort());
$port = $data->getPort();
$login = addslashes($data->getLogin());
$password = addslashes($data->getPassword());

@ -32,13 +32,20 @@ class CreateDatabase extends DatabaseScript {
->addString("email", 64, true)
->addString("name", 32)
->addString("password", 128)
->addString("fullName", 64, false, "")
->addString("profilePicture", 64, true)
->addDateTime("last_online", true, NULL)
->addBool("confirmed", false)
->addInt("language_id", true, 1)
->addInt("gpg_id", true)
->addInt("2fa_id", true)
->addDateTime("registered_at", false, $sql->currentTimestamp())
->primaryKey("uid")
->unique("email")
->unique("name")
->foreignKey("language_id", "Language", "uid", new SetNullStrategy());
->foreignKey("language_id", "Language", "uid", new SetNullStrategy())
->foreignKey("gpg_id", "GpgKey", "uid", new SetNullStrategy())
->foreignKey("2fa_id", "2FA", "uid", new SetNullStrategy());
$queries[] = $sql->createTable("Session")
->addSerial("uid")
@ -57,7 +64,7 @@ class CreateDatabase extends DatabaseScript {
$queries[] = $sql->createTable("UserToken")
->addInt("user_id")
->addString("token", 36)
->addEnum("token_type", array("password_reset", "email_confirm", "invite"))
->addEnum("token_type", array("password_reset", "email_confirm", "invite", "gpg_confirm"))
->addDateTime("valid_until")
->addBool("used", false)
->foreignKey("user_id", "User", "uid", new CascadeStrategy());
@ -131,11 +138,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("^/resendConfirmEmail/?$", "dynamic", "\\Documents\\Account", "\\Views\\Account\\ResendConfirmEmail")
->addRow("^/register/?$", "dynamic", "\\Documents\\Account", "account/register.twig")
->addRow("^/confirmEmail/?$", "dynamic", "\\Documents\\Account", "account/confirm_email.twig")
->addRow("^/acceptInvite/?$", "dynamic", "\\Documents\\Account", "account/accept_invite.twig")
->addRow("^/resetPassword/?$", "dynamic", "\\Documents\\Account", "account/reset_password.twig")
->addRow("^/resendConfirmEmail/?$", "dynamic", "\\Documents\\Account", "account/resend_confirm_email.twig")
->addRow("^/$", "static", "/static/welcome.html", NULL);
$queries[] = $sql->createTable("Settings")
@ -152,10 +159,8 @@ class CreateDatabase extends DatabaseScript {
->addRow("mail_username", "", false, false)
->addRow("mail_password", "", true, false)
->addRow("mail_from", "", false, false)
->addRow("mail_last_sync", "", true, false)
->addRow("message_confirm_email", self::MessageConfirmEmail(), false, false)
->addRow("message_accept_invite", self::MessageAcceptInvite(), false, false)
->addRow("message_reset_password", self::MessageResetPassword(), false, false);
->addRow("mail_last_sync", "", false, false)
->addRow("mail_footer", "", false, false);
(Settings::loadDefaults())->addRows($settingsQuery);
$queries[] = $settingsQuery;
@ -183,13 +188,54 @@ class CreateDatabase extends DatabaseScript {
->foreignKey("request_id", "ContactRequest", "uid", new CascadeStrategy())
->foreignKey("user_id", "User", "uid", new SetNullStrategy());
$queries[] = $sql->createTable("ApiPermission")
->addString("method", 32)
->addJson("groups", true, '[]')
->addString("description", 128, false, "")
->primaryKey("method");
$queries[] = $sql->createTable("MailQueue")
->addSerial("uid")
->addString("from", 64)
->addString("to", 64)
->addString("subject")
->addString("body")
->addString("replyTo", 64, true)
->addString("replyName", 32, true)
->addString("gpgFingerprint", 64, true)
->addEnum("status", ["waiting","success","error"], false, 'waiting')
->addInt("retryCount", false, 5)
->addDateTime("nextTry", false, $sql->now())
->addString("errorMessage", NULL, true)
->primaryKey("uid");
$queries = array_merge($queries, \Configuration\Patch\log::createTableLog($sql, "MailQueue", 30));
$queries[] = $sql->createTable("GpgKey")
->addSerial("uid")
->addString("fingerprint", 64)
->addDateTime("added", false, $sql->now())
->addDateTime("expires")
->addBool("confirmed")
->addString("algorithm", 32)
->primaryKey("uid");
$queries[] = $sql->createTable("2FA")
->addSerial("uid")
->addEnum("type", ["totp","fido"])
->addString("data", 512) // either totp secret, fido challenge or fido public key information
->addBool("confirmed", false)
->addDateTime("added", false, $sql->now())
->primaryKey("uid");
$queries[] = $sql->createTable("News")
->addSerial("uid")
->addInt("publishedBy")
->addDateTime("publishedAt", false, $sql->now())
->addString("title", 128)
->addString("text", 1024)
->foreignKey("publishedBy", "User", "uid", new CascadeStrategy())
->primaryKey("uid");
$queries[] = $sql->insert("ApiPermission", array("method", "groups", "description"))
->addRow("ApiKey/create", array(), "Allows users to create API-Keys for themselves")
->addRow("ApiKey/fetch", array(), "Allows users to list their API-Keys")
@ -222,35 +268,6 @@ class CreateDatabase extends DatabaseScript {
return $queries;
}
private static function MessageConfirmEmail(): string {
return "Hello {{username}},<br>" .
"You recently created an account on {{site_name}}. Please click on the following link to " .
"confirm your email address and complete your registration. If you haven't registered an " .
"account, you can simply ignore this email. The link is valid for the next 48 hours:<br><br> " .
"<a href=\"{{link}}\">{{link}}</a><br><br> " .
"Best Regards<br> " .
"{{site_name}} Administration";
}
private static function MessageAcceptInvite(): string {
return "Hello {{username}},<br>" .
"You were invited to create an account on {{site_name}}. Please click on the following link to " .
"confirm your email address and complete your registration by choosing a new password. " .
"If you want to decline the invitation, you can simply ignore this email. The link is valid for the next 7 days:<br><br>" .
"<a href=\"{{link}}\">{{link}}</a><br><br>" .
"Best Regards<br>" .
"{{site_name}} Administration";
}
private static function MessageResetPassword(): string {
return "Hello {{username}},<br>" .
"you requested a password reset on {{site_name}}. Please click on the following link to " .
"choose a new password. If this request was not intended, you can simply ignore the email. The Link is valid for one hour:<br><br>" .
"<a href=\"{{link}}\">{{link}}</a><br><br>" .
"Best Regards<br>" .
"{{site_name}} Administration";
}
private static function loadPatches(&$queries, $sql) {
$patchDirectory = './core/Configuration/Patch/';
if (file_exists($patchDirectory) && is_dir($patchDirectory)) {

@ -1,75 +0,0 @@
<?php
namespace Configuration\Patch;
use Configuration\DatabaseScript;
use Driver\SQL\SQL;
use Driver\SQL\Column\Column;
use Driver\SQL\Strategy\CascadeStrategy;
use Driver\SQL\Strategy\UpdateStrategy;
class file_api extends DatabaseScript {
public static function createQueries(SQL $sql): array {
$queries = array();
$queries[] = $sql->insert("ApiPermission", array("method", "groups", "description"))
->onDuplicateKeyStrategy(new UpdateStrategy(array("method"), array("method" => new Column("method"))))
->addRow("File/GetRestrictions", array(), "Allows users to view global upload restrictions")
->addRow("File/Download", array(), "Allows users to download files when logged in, or using a given token")
->addRow("File/Upload", array(), "Allows users to upload files when logged in, or using a given token")
->addRow("File/ValidateToken", array(), "Allows users to validate a given token")
->addRow("File/RevokeToken", array(USER_GROUP_ADMIN), "Allows users to revoke a token")
->addRow("File/ListFiles", array(), "Allows users to list all files assigned to an account")
->addRow("File/ListTokens", array(USER_GROUP_ADMIN), "Allows users to list all tokens assigned to the virtual filesystem of an account")
->addRow("File/CreateDirectory", array(), "Allows users to create a virtual directory")
->addRow("File/Rename", array(), "Allows users to rename files in the virtual filesystem")
->addRow("File/Move", array(), "Allows users to move files in the virtual filesystem")
->addRow("File/Delete", array(), "Allows users to delete files in the virtual filesystem")
->addRow("File/CreateUploadToken", array(USER_GROUP_ADMIN), "Allows users to create a token to upload files to the virtual filesystem assigned to the users account")
->addRow("File/CreateDownloadToken", array(USER_GROUP_ADMIN), "Allows users to create a token to download files from the virtual filesystem assigned to the users account");
$queries[] = $sql->insert("Route", array("request", "action", "target", "extra"))
->onDuplicateKeyStrategy(new UpdateStrategy(array("request"), array("request" => new Column("request"))))
->addRow("^/files(/.*)?$", "dynamic", "\\Documents\\Files", NULL);
$queries[] = $sql->createTable("UserFile")
->onlyIfNotExists()
->addSerial("uid")
->addBool("directory")
->addString("name", 64, false)
->addString("path", 512, true)
->addInt("parent_id", true)
->addInt("user_id")
->primaryKey("uid")
->unique("user_id", "parent_id", "name")
->foreignKey("parent_id", "UserFile", "uid", new CascadeStrategy())
->foreignKey("user_id", "User", "uid", new CascadeStrategy());
$queries[] = $sql->createTable("UserFileToken")
->onlyIfNotExists()
->addSerial("uid")
->addString("token", 36, false)
->addDateTime("valid_until", true)
->addEnum("token_type", array("download", "upload"))
->addInt("user_id")
# upload only:
->addInt("maxFiles", true)
->addInt("maxSize", true)
->addInt("parent_id", true)
->addString("extensions", 64, true)
->primaryKey("uid")
->foreignKey("user_id", "User", "uid", new CascadeStrategy())
->foreignKey("parent_id", "UserFile", "uid", new CascadeStrategy());
$queries[] = $sql->createTable("UserFileTokenFile")
->addInt("file_id")
->addInt("token_id")
->unique("file_id", "token_id")
->foreignKey("file_id", "UserFile", "uid", new CascadeStrategy())
->foreignKey("token_id", "UserFileToken", "uid", new CascadeStrategy());
return $queries;
}
}

@ -5,6 +5,7 @@ namespace Configuration\Patch;
use Configuration\DatabaseScript;
use Driver\SQL\Column\IntColumn;
use Driver\SQL\Condition\Compare;
use Driver\SQL\Query\CreateProcedure;
use Driver\SQL\SQL;
use Driver\SQL\Type\CurrentColumn;
use Driver\SQL\Type\CurrentTable;
@ -12,6 +13,32 @@ use Driver\SQL\Type\Trigger;
class log extends DatabaseScript {
public static function createTableLog(SQL $sql, string $table, int $lifetime = 90): array {
return [
$sql->createTrigger("${table}_trg_insert")
->after()->insert($table)
->exec(new CreateProcedure($sql, "InsertEntityLog"), [
"tableName" => new CurrentTable(),
"entityId" => new CurrentColumn("uid"),
"lifetime" => $lifetime,
]),
$sql->createTrigger("${table}_trg_update")
->after()->update($table)
->exec(new CreateProcedure($sql, "UpdateEntityLog"), [
"tableName" => new CurrentTable(),
"entityId" => new CurrentColumn("uid"),
]),
$sql->createTrigger("${table}_trg_delete")
->after()->delete($table)
->exec(new CreateProcedure($sql, "DeleteEntityLog"), [
"tableName" => new CurrentTable(),
"entityId" => new CurrentColumn("uid"),
])
];
}
public static function createQueries(SQL $sql): array {
$queries = array();
@ -25,10 +52,11 @@ class log extends DatabaseScript {
$insertProcedure = $sql->createProcedure("InsertEntityLog")
->param(new CurrentTable())
->param(new IntColumn("uid"))
->param(new IntColumn("lifetime", false, 90))
->returns(new Trigger())
->exec(array(
$sql->insert("EntityLog", ["entityId", "tableName"])
->addRow(new CurrentColumn("uid"), new CurrentTable())
$sql->insert("EntityLog", ["entityId", "tableName", "lifetime"])
->addRow(new CurrentColumn("uid"), new CurrentTable(), new CurrentColumn("lifetime"))
));
$updateProcedure = $sql->createProcedure("UpdateEntityLog")
@ -58,18 +86,7 @@ class log extends DatabaseScript {
$tables = ["ContactRequest"];
foreach ($tables as $table) {
$queries[] = $sql->createTrigger("${table}_trg_insert")
->after()->insert($table)
->exec($insertProcedure);
$queries[] = $sql->createTrigger("${table}_trg_update")
->after()->update($table)
->exec($updateProcedure);
$queries[] = $sql->createTrigger("${table}_trg_delete")
->after()->delete($table)
->exec($deleteProcedure);
$queries = array_merge($queries, self::createTableLog($sql, $table));
}
return $queries;

@ -23,6 +23,8 @@ class Settings {
private bool $mailEnabled;
private string $recaptchaPublicKey;
private string $recaptchaPrivateKey;
private string $mailSender;
private string $mailFooter;
public function getJwtSecret(): string {
return $this->jwtSecret;
@ -47,6 +49,9 @@ class Settings {
$settings->recaptchaPrivateKey = "";
$settings->recaptchaEnabled = false;
$settings->mailEnabled = false;
$settings->mailSender = "webmaster@localhost";
$settings->mailFooter = "";
return $settings;
}
@ -65,6 +70,8 @@ class Settings {
$this->recaptchaPublicKey = $result["recaptcha_public_key"] ?? $this->recaptchaPublicKey;
$this->recaptchaPrivateKey = $result["recaptcha_private_key"] ?? $this->recaptchaPrivateKey;
$this->mailEnabled = $result["mail_enabled"] ?? $this->mailEnabled;
$this->mailSender = $result["mail_from"] ?? $this->mailSender;
$this->mailFooter = $result["mail_footer"] ?? $this->mailFooter;
if (!isset($result["jwt_secret"])) {
$req = new \Api\Settings\Set($user);
@ -115,4 +122,8 @@ class Settings {
public function isMailEnabled(): bool {
return $this->mailEnabled;
}
public function getMailSender(): string {
return $this->mailSender;
}
}

@ -2,6 +2,9 @@
namespace Driver\SQL;
use Driver\SQL\Column\Column;
use Driver\SQL\Condition\Compare;
class Join {
private string $type;
@ -9,13 +12,16 @@ class Join {
private string $columnA;
private string $columnB;
private ?string $tableAlias;
private array $conditions;
public function __construct(string $type, string $table, string $columnA, string $columnB, ?string $tableAlias = null) {
public function __construct(string $type, string $table, string $columnA, string $columnB, ?string $tableAlias = null, array $conditions = []) {
$this->type = $type;
$this->table = $table;
$this->columnA = $columnA;
$this->columnB = $columnB;
$this->tableAlias = $tableAlias;
$this->conditions = $conditions;
array_unshift($this->conditions , new Compare($columnA, new Column($columnB), "="));
}
public function getType(): string { return $this->type; }
@ -23,5 +29,6 @@ class Join {
public function getColumnA(): string { return $this->columnA; }
public function getColumnB(): string { return $this->columnB; }
public function getTableAlias(): ?string { return $this->tableAlias; }
public function getConditions(): array { return $this->conditions; }
}

@ -316,7 +316,13 @@ class MySQL extends SQL {
foreach($table as $t) $tables[] = $this->tableName($t);
return implode(",", $tables);
} else {
return "`$table`";
$parts = explode(" ", $table);
if (count($parts) === 2) {
list ($name, $alias) = $parts;
return "`$name` $alias";
} else {
return "`$table`";
}
}
}
@ -346,15 +352,17 @@ class MySQL extends SQL {
return mysqli_stat($this->connection);
}
public function createTriggerBody(CreateTrigger $trigger): ?string {
public function createTriggerBody(CreateTrigger $trigger, array $parameters = []): ?string {
$values = array();
foreach ($trigger->getProcedure()->getParameters() as $param) {
if ($param instanceof CurrentTable) {
foreach ($parameters as $paramValue) {
if ($paramValue instanceof CurrentTable) {
$values[] = $this->getUnsafeValue($trigger->getTable());
} else {
} elseif ($paramValue instanceof CurrentColumn) {
$prefix = ($trigger->getEvent() !== "DELETE" ? "NEW." : "OLD.");
$values[] = $this->columnName($prefix . $param->getName());
$values[] = $this->columnName($prefix . $paramValue->getName());
} else {
$values[] = $paramValue;
}
}

@ -4,7 +4,6 @@ namespace Driver\SQL;
use \Api\Parameter\Parameter;
use Api\User\Create;
use Driver\SQL\Column\Column;
use \Driver\SQL\Column\IntColumn;
use \Driver\SQL\Column\SerialColumn;
@ -304,7 +303,13 @@ class PostgreSQL extends SQL {
foreach($table as $t) $tables[] = $this->tableName($t);
return implode(",", $tables);
} else {
return "\"$table\"";
$parts = explode(" ", $table);
if (count($parts) === 2) {
list ($name, $alias) = $parts;
return "\"$name\" $alias";
} else {
return "\"$table\"";
}
}
}
@ -366,13 +371,10 @@ class PostgreSQL extends SQL {
$query .= "END;";
$query .= "\$table\$ LANGUAGE plpgsql;";
var_dump($query);
var_dump($params);
return $this->execute($query, $params);
}
public function createTriggerBody(CreateTrigger $trigger): ?string {
public function createTriggerBody(CreateTrigger $trigger, array $params = []): ?string {
$procName = $this->tableName($trigger->getProcedure()->getName());
return "EXECUTE PROCEDURE $procName()";
}

@ -11,6 +11,7 @@ class CreateTrigger extends Query {
private string $time;
private string $event;
private string $tableName;
private array $parameters;
private ?CreateProcedure $procedure;
public function __construct(SQL $sql, string $triggerName) {
@ -19,6 +20,7 @@ class CreateTrigger extends Query {
$this->time = "AFTER";
$this->tableName = "";
$this->event = "";
$this->parameters = [];
$this->procedure = null;
}
@ -50,8 +52,9 @@ class CreateTrigger extends Query {
return $this;
}
public function exec(CreateProcedure $procedure): CreateTrigger {
public function exec(CreateProcedure $procedure, array $parameters = []): CreateTrigger {
$this->procedure = $procedure;
$this->parameters = $parameters;
return $this;
}
@ -69,7 +72,7 @@ class CreateTrigger extends Query {
$params = array();
$query = "CREATE TRIGGER $name $time $event ON $tableName FOR EACH ROW ";
$triggerBody = $this->sql->createTriggerBody($this);
$triggerBody = $this->sql->createTriggerBody($this, $this->parameters);
if ($triggerBody === null) {
return null;
}

@ -3,6 +3,7 @@
namespace Driver\SQL\Query;
use Driver\SQL\Condition\CondOr;
use Driver\SQL\Expression\JsonArrayAgg;
use Driver\SQL\Join;
use Driver\SQL\SQL;
@ -47,13 +48,13 @@ class Select extends Query {
return $this;
}
public function innerJoin(string $table, string $columnA, string $columnB, ?string $tableAlias = null): Select {
$this->joins[] = new Join("INNER", $table, $columnA, $columnB, $tableAlias);
public function innerJoin(string $table, string $columnA, string $columnB, ?string $tableAlias = null, array $conditions = []): Select {
$this->joins[] = new Join("INNER", $table, $columnA, $columnB, $tableAlias, $conditions);
return $this;
}
public function leftJoin(string $table, string $columnA, string $columnB, ?string $tableAlias = null): Select {
$this->joins[] = new Join("LEFT", $table, $columnA, $columnB, $tableAlias);
public function leftJoin(string $table, string $columnA, string $columnB, ?string $tableAlias = null, array $conditions = []): Select {
$this->joins[] = new Join("LEFT", $table, $columnA, $columnB, $tableAlias, $conditions);
return $this;
}
@ -113,11 +114,19 @@ class Select extends Query {
if (count($value->getSelectValues()) !== 1) {
$selectValues[] = "($subSelect)";
} else {
$columnName = $value->getSelectValues()[0];
if(($index = stripos($columnName, " as ")) !== FALSE) {
$columnName = substr($columnName, $index + 4);
$columnAlias = null;
$subSelectColumn = $value->getSelectValues()[0];
if (is_string($subSelectColumn) && ($index = stripos($subSelectColumn, " as ")) !== FALSE) {
$columnAlias = substr($subSelectColumn, $index + 4);
} else if ($subSelectColumn instanceof JsonArrayAgg) {
$columnAlias = $subSelectColumn->getAlias();
}
if ($columnAlias) {
$selectValues[] = "($subSelect) as $columnAlias";
} else {
$selectValues[] = "($subSelect)";
}
$selectValues[] = "($subSelect) as $columnName";
}
} else {
$selectValues[] = $this->sql->addValue($value, $params);
@ -144,11 +153,9 @@ class Select extends Query {
foreach ($joins as $join) {
$type = $join->getType();
$joinTable = $this->sql->tableName($join->getTable());
$columnA = $this->sql->columnName($join->getColumnA());
$columnB = $this->sql->columnName($join->getColumnB());
$tableAlias = ($join->getTableAlias() ? " " . $join->getTableAlias() : "");
$joinStr .= " $type JOIN $joinTable$tableAlias ON $columnA=$columnB";
$condition = $this->sql->buildCondition($join->getConditions(), $params);
$joinStr .= " $type JOIN $joinTable$tableAlias ON ($condition)";
}
}

@ -14,9 +14,9 @@ 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;
use \Driver\SQL\Constraint\ForeignKey;
use Driver\SQL\Constraint\Unique;
use Driver\SQL\Constraint\PrimaryKey;
use Driver\SQL\Constraint\ForeignKey;
use Driver\SQL\Expression\CaseWhen;
use Driver\SQL\Expression\CurrentTimeStamp;
use Driver\SQL\Expression\Expression;
@ -176,7 +176,7 @@ abstract class SQL {
protected abstract function fetchReturning($res, string $returningCol);
public abstract function getColumnDefinition(Column $column): ?string;
public abstract function getOnDuplicateStrategy(?Strategy $strategy, &$params): ?string;
public abstract function createTriggerBody(CreateTrigger $trigger): ?string;
public abstract function createTriggerBody(CreateTrigger $trigger, array $params = []): ?string;
public abstract function getProcedureHead(CreateProcedure $procedure): ?string;
public abstract function getColumnType(Column $column): ?string;
public function getProcedureTail(): string { return ""; }

@ -11,12 +11,16 @@ abstract class Document {
protected bool $databaseRequired;
private bool $cspEnabled;
private ?string $cspNonce;
private array $cspWhitelist;
private string $domain;
public function __construct(User $user) {
$this->user = $user;
$this->cspEnabled = false;
$this->cspNonce = null;
$this->databaseRequired = true;
$this->cspWhitelist = [];
$this->domain = $user->getConfiguration()->getSettings()->getBaseUrl();
}
public function getSQL(): ?SQL {
@ -40,6 +44,10 @@ abstract class Document {
$this->cspNonce = generateRandomString(16, "base62");
}
protected function addCSPWhitelist(string $path) {
$this->cspWhitelist[] = $this->domain . $path;
}
public function getCode(): string {
if ($this->databaseRequired) {
$sql = $this->user->getSQL();
@ -51,12 +59,22 @@ abstract class Document {
}
if ($this->cspEnabled) {
$csp = ["default-src 'self'", "object-src 'none'", "base-uri 'self'", "style-src 'self' 'unsafe-inline'", "script-src 'nonce-$this->cspNonce'"];
$cspWhiteList = implode(" ", $this->cspWhitelist);
$csp = [
"default-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"script-src $cspWhiteList 'nonce-$this->cspNonce'"
];
if ($this->user->getConfiguration()->getSettings()->isRecaptchaEnabled()) {
$csp[] = "frame-src https://www.google.com/ 'self'";
}
$compiledCSP = implode(";", $csp);
$compiledCSP = implode("; ", $csp);
header("Content-Security-Policy: $compiledCSP;");
}

@ -15,9 +15,11 @@ class TemplateDocument extends Document {
protected array $parameters;
private Environment $twigEnvironment;
private FilesystemLoader $twigLoader;
protected string $title;
public function __construct(User $user, string $templateName, array $initialParameters = []) {
parent::__construct($user);
$this->title = "";
$this->templateName = $templateName;
$this->parameters = $initialParameters;
$this->twigLoader = new FilesystemLoader(WEBROOT . '/core/Templates');
@ -47,12 +49,16 @@ class TemplateDocument extends Document {
$params["user"] = [
"lang" => $this->user->getLanguage()->getShortCode(),
"loggedIn" => $this->user->isLoggedIn(),
"session" => (!$this->user->isLoggedIn() ? null : [
"csrfToken" => $this->user->getSession()->getCsrfToken()
])
];
$settings = $this->user->getConfiguration()->getSettings();
$params["site"] = [
"name" => $settings->getSiteName(),
"baseUrl" => $settings->getBaseUrl(),
"title" => $this->title,
"recaptcha" => [
"key" => $settings->isRecaptchaEnabled() ? $settings->getRecaptchaSiteKey() : null,
"enabled" => $settings->isRecaptchaEnabled(),

@ -1,6 +1,11 @@
{
"require": {
"twig/twig": "^3.0"
"php-mqtt/client": "^1.1",
"twig/twig": "^3.0",
"chillerlan/php-qrcode": "^4.3",
"christian-riesen/base32": "^1.6",
"spomky-labs/cbor-php": "2.0.1",
"web-auth/cose-lib": "^3.3"
},
"require-dev": {
"phpunit/phpunit": "^9.5"

@ -9,6 +9,14 @@ class AesStream {
private $callback;
private ?string $outputFile;
private ?string $inputFile;
private int $offset;
private ?int $length;
//
private ?string $md5SumIn;
private ?string $sha1SumIn;
private ?string $md5SumOut;
private ?string $sha1SumOut;
public function __construct(string $key, string $iv) {
$this->key = $key;
@ -16,6 +24,12 @@ class AesStream {
$this->inputFile = null;
$this->outputFile = null;
$this->callback = null;
$this->offset = 0;
$this->length = null;
$this->md5SumIn = null;
$this->sha1SumIn = null;
$this->md5SumOut = null;
$this->sha1SumOut = null;
if (!in_array(strlen($key), [16, 24, 32])) {
throw new \Exception("Invalid Key Size");
@ -59,14 +73,23 @@ class AesStream {
}
set_time_limit(0);
$md5ContextIn = hash_init("md5");
$sha1ContextIn = hash_init("sha1");
$md5ContextOut = hash_init("md5");
$sha1ContextOut = hash_init("sha1");
$ivCounter = $this->iv;
$modulo = \gmp_init("0x1" . str_repeat("00", $blockSize), 16);
$written = 0;
while (!feof($inputHandle)) {
$chunk = fread($inputHandle, 65536);
$chunkSize = strlen($chunk);
if ($chunkSize > 0) {
hash_update($md5ContextIn, $chunk);
hash_update($sha1ContextIn, $chunk);
$blockCount = intval(ceil($chunkSize / $blockSize));
$encrypted = openssl_encrypt($chunk, $aesMode, $this->key, OPENSSL_RAW_DATA | OPENSSL_NO_PADDING, $ivCounter);
@ -76,18 +99,50 @@ class AesStream {
$ivNumber = str_pad(\gmp_strval($ivNumber, 16), $blockSize * 2, "0", STR_PAD_LEFT);
$ivCounter = hex2bin($ivNumber);
if ($this->callback !== null) {
call_user_func($this->callback, $encrypted);
// partial content
$skip = false;
if ($this->offset > 0 && $written < $this->offset) {
if ($written + $chunkSize >= $this->offset) {
$encrypted = substr($encrypted, $this->offset - $written);
} else {
$skip = true;
}
}
if ($outputHandle !== null) {
fwrite($outputHandle, $encrypted);
if ($this->length !== null) {
$notSkipped = max($written - $this->offset, 0);
if ($notSkipped + $chunkSize >= $this->length) {
$encrypted = substr($encrypted, 0, $this->length - $notSkipped);
}
}
if (!$skip) {
if ($this->callback !== null) {
call_user_func($this->callback, $encrypted);
}
if ($outputHandle !== null) {
fwrite($outputHandle, $encrypted);
}
hash_update($md5ContextOut, $encrypted);
hash_update($sha1ContextOut, $encrypted);
}
$written += $chunkSize;
if ($this->length !== null && $written - $this->offset >= $this->length) {
break;
}
}
}
fclose($inputHandle);
if ($outputHandle) fclose($outputHandle);
if ($outputHandle) {
fclose($outputHandle);
}
$this->md5SumIn = hash_final($md5ContextIn, false);
$this->sha1SumIn = hash_final($sha1ContextIn, false);
return true;
}
@ -103,4 +158,25 @@ class AesStream {
public function getIV(): string {
return $this->iv;
}
public function setRange(int $offset, int $length) {
$this->offset = $offset;
$this->length = $length;
}
public function getMD5SumIn(): ?string {
return $this->md5SumIn;
}
public function getSHA1SumIn(): ?string {
return $this->sha1SumIn;
}
public function getMD5SumOut(): ?string {
return $this->md5SumOut;
}
public function getSHA1SumOut(): ?string {
return $this->sha1SumOut;
}
}

@ -6,6 +6,6 @@ abstract class ApiObject implements \JsonSerializable {
public abstract function jsonSerialize(): array;
public function __toString() { return json_encode($this); }
public function __toString() { return json_encode($this->jsonSerialize()); }
}

@ -0,0 +1,136 @@
<?php
namespace Objects;
class GpgKey extends ApiObject {
const GPG2 = "/usr/bin/gpg2";
private int $id;
private bool $confirmed;
private string $fingerprint;
private string $algorithm;
private \DateTime $expires;
public function __construct(int $id, bool $confirmed, string $fingerprint, string $algorithm, string $expires) {
$this->id = $id;
$this->confirmed = $confirmed;
$this->fingerprint = $fingerprint;
$this->algorithm = $algorithm;
$this->expires = new \DateTime($expires);
}
public static function encrypt(string $body, string $gpgFingerprint): array {
$gpgFingerprint = escapeshellarg($gpgFingerprint);
$cmd = self::GPG2 . " --encrypt --output - --recipient $gpgFingerprint --trust-model always --batch --armor";
list($out, $err) = self::proc_exec($cmd, $body, true);
if ($out === null) {
return self::createError("Error while communicating with GPG agent");
} else if ($err) {
return self::createError($err);
} else {
return ["success" => true, "data" => $out];
}
}
public function jsonSerialize(): array {
return array(
"fingerprint" => $this->fingerprint,
"algorithm" => $this->algorithm,
"expires" => $this->expires->getTimestamp(),
"confirmed" => $this->confirmed
);
}
private static function proc_exec(string $cmd, ?string $stdin = null, bool $raw = false): ?array {
$descriptorSpec = array(0 => ["pipe", "r"], 1 => ["pipe", "w"], 2 => ["pipe", "w"]);
$process = proc_open($cmd, $descriptorSpec,$pipes);
if (!is_resource($process)) {
return null;
}
if ($stdin) {
fwrite($pipes[0], $stdin);
fclose($pipes[0]);
}
$out = stream_get_contents($pipes[1]);
$err = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($process);
return [($raw ? $out : trim($out)), $err];
}
private static function createError(string $error) : array {
return ["success" => false, "error" => $error];
}
public static function getKeyInfo(string $key): array {
list($out, $err) = self::proc_exec(self::GPG2 . " --show-key", $key);
if ($out === null) {
return self::createError("Error while communicating with GPG agent");
}
if ($err) {
return self::createError($err);
}
$lines = explode("\n", $out);
if (count($lines) > 4) {
return self::createError("It seems like you have uploaded more than one GPG-Key");
} else if (count($lines) !== 4 || !preg_match("/(\S+)\s+(\w+)\s+.*\[expires: ([0-9-]+)]/", $lines[0], $matches)) {
return self::createError("Error parsing GPG output");
}
$keyType = $matches[1];
$keyAlg = $matches[2];
$expires = \DateTime::createFromFormat("Y-m-d", $matches[3]);
$fingerprint = trim($lines[1]);
$keyData = ["type" => $keyType, "algorithm" => $keyAlg, "expires" => $expires, "fingerprint" => $fingerprint];
return ["success" => true, "data" => $keyData];
}
public static function importKey(string $key): array {
list($out, $err) = self::proc_exec(self::GPG2 . " --import", $key);
if ($out === null) {
return self::createError("Error while communicating with GPG agent");
}
if (preg_match("/gpg:\s+Total number processed:\s+(\d+)/", $err, $matches) && intval($matches[1]) > 0) {
if ((preg_match("/.*\s+unchanged:\s+(\d+)/", $err, $matches) && intval($matches[1]) > 0) ||
(preg_match("/.*\s+imported:\s+(\d+)/", $err, $matches) && intval($matches[1]) > 0)) {
return ["success" => true];
}
}
return self::createError($err);
}
public static function export($gpgFingerprint, bool $armored): array {
$cmd = self::GPG2 . " --export ";
if ($armored) {
$cmd .= "--armor ";
}
$cmd .= escapeshellarg($gpgFingerprint);
list($out, $err) = self::proc_exec($cmd);
if ($err) {
return self::createError($err);
}
return ["success" => true, "data" => $out];
}
public function isConfirmed(): bool {
return $this->confirmed;
}
public function getId(): int {
return $this->id;
}
public function getFingerprint(): string {
return $this->fingerprint;
}
}

@ -0,0 +1,13 @@
<?php
namespace Objects;
class KeyBasedTwoFactorToken extends TwoFactorToken {
const TYPE = "fido2";
public function __construct(string $secret, ?int $id = null, bool $confirmed = false) {
parent::__construct(self::TYPE, $secret, $id, $confirmed);
}
}

@ -47,14 +47,16 @@ namespace Objects {
return $key;
}
public function sendCookie() {
setcookie('lang', $this->langCode, 0, "/", "");
public function sendCookie(?string $domain = null) {
$domain = empty($domain) ? "" : $domain;
setcookie('lang', $this->langCode, 0, "/", $domain, false, false);
}
public function jsonSerialize(): array {
return array(
'uid' => $this->languageId,
'code' => $this->langCode,
'shortCode' => explode("_", $this->langCode)[0],
'name' => $this->langName,
);
}

@ -11,7 +11,7 @@ use External\JWT;
class Session extends ApiObject {
# in minutes
const DURATION = 60*24;
const DURATION = 60*60*24*14;
private ?int $sessionId;
private User $user;
@ -25,13 +25,14 @@ class Session extends ApiObject {
public function __construct(User $user, ?int $sessionId, ?string $csrfToken) {
$this->user = $user;
$this->sessionId = $sessionId;
$this->stayLoggedIn = true;
$this->stayLoggedIn = false;
$this->csrfToken = $csrfToken ?? generateRandomString(16);
}
public static function create($user, $stayLoggedIn): ?Session {
public static function create(User $user, bool $stayLoggedIn = false): ?Session {
$session = new Session($user, null, null);
if($session->insert($stayLoggedIn)) {
if ($session->insert($stayLoggedIn)) {
$session->stayLoggedIn = $stayLoggedIn;
return $session;
}
@ -39,8 +40,8 @@ class Session extends ApiObject {
}
private function updateMetaData() {
$this->expires = time() + Session::DURATION * 60;
$this->ipAddress = $_SERVER['REMOTE_ADDR'];
$this->expires = time() + Session::DURATION;
$this->ipAddress = is_cli() ? "127.0.0.1" : $_SERVER['REMOTE_ADDR'];
try {
$userAgent = @get_browser($_SERVER['HTTP_USER_AGENT'], true);
$this->os = $userAgent['platform'] ?? "Unknown";
@ -51,31 +52,36 @@ class Session extends ApiObject {
}
}
public function setData($data) {
public function setData(array $data) {
foreach($data as $key => $value) {
$_SESSION[$key] = $value;
}
}
public function stayLoggedIn($val) {
public function stayLoggedIn(bool $val) {
$this->stayLoggedIn = $val;
}
public function sendCookie() {
public function getCookie(): string {
$this->updateMetaData();
$settings = $this->user->getConfiguration()->getSettings();
$token = array('userId' => $this->user->getId(), 'sessionId' => $this->sessionId);
$sessionCookie = JWT::encode($token, $settings->getJwtSecret());
$token = ['userId' => $this->user->getId(), 'sessionId' => $this->sessionId];
return JWT::encode($token, $settings->getJwtSecret());
}
public function sendCookie(?string $domain = null) {
$domain = empty($domain) ? "" : $domain;
$sessionCookie = $this->getCookie();
$secure = strcmp(getProtocol(), "https") === 0;
setcookie('session', $sessionCookie, $this->getExpiresTime(), "/", "", $secure, true);
setcookie('session', $sessionCookie, $this->getExpiresTime(), "/", $domain, $secure, true);
}
public function getExpiresTime(): int {
return ($this->stayLoggedIn == 0 ? 0 : $this->expires);
return ($this->stayLoggedIn ? $this->expires : 0);
}
public function getExpiresSeconds(): int {
return ($this->stayLoggedIn == 0 ? -1 : $this->expires - time());
return ($this->stayLoggedIn ? $this->expires - time() : -1);
}
public function jsonSerialize(): array {
@ -90,7 +96,7 @@ class Session extends ApiObject {
);
}
public function insert($stayLoggedIn): bool {
public function insert(bool $stayLoggedIn = false): bool {
$this->updateMetaData();
$sql = $this->user->getSQL();
@ -105,13 +111,13 @@ class Session extends ApiObject {
$this->ipAddress,
$this->os,
$this->browser,
json_encode($_SESSION),
json_encode($_SESSION ?? []),
$stayLoggedIn,
$this->csrfToken)
->returning("uid")
->execute();
if($success) {
if ($success) {
$this->sessionId = $this->user->getSQL()->getLastInsertId();
return true;
}
@ -120,6 +126,7 @@ class Session extends ApiObject {
}
public function destroy(): bool {
session_destroy();
return $this->user->getSQL()->update("Session")
->set("active", false)
->where(new Compare("Session.uid", $this->sessionId))
@ -138,7 +145,7 @@ class Session extends ApiObject {
->where(new Compare("uid", $this->user->getId()))
->execute() &&
$sql->update("Session")
->set("Session.expires", (new DateTime())->modify("+$minutes minute"))
->set("Session.expires", (new DateTime())->modify("+$minutes second"))
->set("Session.ipAddress", $this->ipAddress)
->set("Session.os", $this->os)
->set("Session.browser", $this->browser)

@ -0,0 +1,52 @@
<?php
namespace Objects;
use Base32\Base32;
use chillerlan\QRCode\QRCode;
use chillerlan\QRCode\QROptions;
class TimeBasedTwoFactorToken extends TwoFactorToken {
const TYPE = "totp";
public function __construct(string $secret, ?int $id = null, bool $confirmed = false) {
parent::__construct(self::TYPE, $secret, $id, $confirmed);
}
public function getUrl(User $user): string {
$otpType = self::TYPE;
$name = rawurlencode($user->getUsername());
$settings = $user->getConfiguration()->getSettings();
$urlArgs = [
"secret" => $this->getSecret(),
"issuer" => $settings->getSiteName(),
];
$urlArgs = http_build_query($urlArgs);
return "otpauth://$otpType/$name?$urlArgs";
}
public function generateQRCode(User $user) {
$options = new QROptions(['outputType' => QRCode::OUTPUT_IMAGE_PNG, "imageBase64" => false]);
$qrcode = new QRCode($options);
return $qrcode->render($this->getUrl($user));
}
public function generate(?int $at = null, int $length = 6, int $period = 30): string {
if ($at === null) {
$at = time();
}
$seed = intval($at / $period);
$secret = Base32::decode($this->getSecret());
$hmac = hash_hmac('sha1', pack("J", $seed), $secret, true);
$offset = ord($hmac[-1]) & 0xF;
$code = (unpack("N", substr($hmac, $offset, 4))[1] & 0x7fffffff) % intval(pow(10, $length));
return substr(str_pad(strval($code), $length, "0", STR_PAD_LEFT), -1 * $length);
}
public function verify(string $code): bool {
return $this->generate() === $code;
}
}

@ -0,0 +1,37 @@
<?php
namespace Objects\TwoFactor;
use Objects\ApiObject;
class AttestationObject extends ApiObject {
use \Objects\TwoFactor\CBORDecoder;
private string $format;
private array $statement;
private AuthenticationData $authData;
public function __construct(string $buffer) {
$data = $this->decode($buffer)->getNormalizedData();
$this->format = $data["fmt"];
$this->statement = $data["attStmt"];
$this->authData = new AuthenticationData($data["authData"]);
}
public function jsonSerialize(): array {
return [
"format" => $this->format,
"statement" => [
"sig" => base64_encode($this->statement["sig"] ?? ""),
"x5c" => base64_encode(($this->statement["x5c"] ?? [""])[0]),
],
"authData" => $this->authData->jsonSerialize()
];
}
public function getAuthData(): AuthenticationData {
return $this->authData;
}
}

@ -0,0 +1,79 @@
<?php
namespace Objects\TwoFactor;
use Objects\ApiObject;
class AuthenticationData extends ApiObject {
private string $rpIDHash;
private int $flags;
private int $counter;
private string $aaguid;
private string $credentialID;
private PublicKey $publicKey;
public function __construct(string $buffer) {
if (strlen($buffer) < 32 + 1 + 4) {
throw new \Exception("Invalid authentication data buffer size");
}
$offset = 0;
$this->rpIDHash = substr($buffer, $offset, 32); $offset += 32;
$this->flags = ord($buffer[$offset]); $offset += 1;
$this->counter = unpack("N", $buffer, $offset)[1]; $offset += 4;
if (strlen($buffer) >= $offset + 4 + 2) {
$this->aaguid = substr($buffer, $offset, 16); $offset += 16;
$credentialIdLength = unpack("n", $buffer, $offset)[1]; $offset += 2;
$this->credentialID = substr($buffer, $offset, $credentialIdLength); $offset += $credentialIdLength;
$credentialData = substr($buffer, $offset);
$this->publicKey = new PublicKey($credentialData);
}
}
public function jsonSerialize(): array {
return [
"rpIDHash" => base64_encode($this->rpIDHash),
"flags" => $this->flags,
"counter" => $this->counter,
"aaguid" => base64_encode($this->aaguid),
"credentialID" => base64_encode($this->credentialID),
"publicKey" => $this->publicKey->jsonSerialize()
];
}
public function getHash(): string {
return $this->rpIDHash;
}
public function verifyIntegrity(string $rp): bool {
return $this->rpIDHash === hash("sha256", $rp, true);
}
public function isUserPresent(): bool {
return boolval($this->flags & (1 << 0));
}
public function isUserVerified(): bool {
return boolval($this->flags & (1 << 2));
}
public function attestedCredentialData(): bool {
return boolval($this->flags & (1 << 6));
}
public function hasExtensionData(): bool {
return boolval($this->flags & (1 << 7));
}
public function getPublicKey(): PublicKey {
return $this->publicKey;
}
public function getCredentialID() {
return $this->credentialID;
}
}

@ -0,0 +1,16 @@
<?php
namespace Objects\TwoFactor;
use CBOR\StringStream;
trait CBORDecoder {
protected function decode(string $buffer): \CBOR\CBORObject {
$objectManager = new \CBOR\OtherObject\OtherObjectManager();
$tagManager = new \CBOR\Tag\TagObjectManager();
$decoder = new \CBOR\Decoder($tagManager, $objectManager);
return $decoder->decode(new StringStream($buffer));
}
}

@ -0,0 +1,74 @@
<?php
namespace Objects\TwoFactor;
use Cose\Algorithm\Signature\ECDSA\ECSignature;
class KeyBasedTwoFactorToken extends TwoFactorToken {
const TYPE = "fido";
private ?string $challenge;
private ?string $credentialId;
private ?PublicKey $publicKey;
public function __construct(string $data, ?int $id = null, bool $confirmed = false) {
parent::__construct(self::TYPE, $id, $confirmed);
if (!$confirmed) {
$this->challenge = base64_decode($data);
$this->credentialId = null;
$this->publicKey = null;
} else {
$jsonData = json_decode($data, true);
$this->challenge = base64_decode($_SESSION["challenge"] ?? "");
$this->credentialId = base64_decode($jsonData["credentialID"]);
$this->publicKey = PublicKey::fromJson($jsonData["publicKey"]);
}
}
public function getData(): string {
return $this->challenge;
}
public function getPublicKey(): ?PublicKey {
return $this->publicKey;
}
public function getCredentialId() {
return $this->credentialId;
}
public function jsonSerialize(): array {
$json = parent::jsonSerialize();
if (!empty($this->challenge) && !$this->isAuthenticated()) {
$json["challenge"] = base64_encode($this->challenge);
}
if (!empty($this->credentialId)) {
$json["credentialID"] = base64_encode($this->credentialId);
}
return $json;
}
// TODO: algorithms, hardcoded values, ...
public function verify(string $signature, string $data): bool {
switch ($this->publicKey->getUsedAlgorithm()) {
case -7: // EC2
if (strlen($signature) !== 64) {
$signature = \Cose\Algorithm\Signature\ECDSA\ECSignature::fromAsn1($signature, 64);
}
$coseKey = new \Cose\Key\Key($this->publicKey->getNormalizedData());
$ec2key = new \Cose\Key\Ec2Key($coseKey->getData());
$publicKey = $ec2key->toPublic();
$signature = ECSignature::toAsn1($signature, 64);
return openssl_verify($data, $signature, $publicKey->asPEM(), "sha256") === 1;
default:
// Not implemented :(
return false;
}
}
}

@ -0,0 +1,67 @@
<?php
namespace Objects\TwoFactor;
use Objects\ApiObject;
class PublicKey extends ApiObject {
use \Objects\TwoFactor\CBORDecoder;
private int $keyType;
private int $usedAlgorithm;
private int $curveType;
private string $xCoordinate;
private string $yCoordinate;
public function __construct(?string $cborData = null) {
if ($cborData) {
$data = $this->decode($cborData)->getNormalizedData();
$this->keyType = $data["1"];
$this->usedAlgorithm = $data["3"];
$this->curveType = $data["-1"];
$this->xCoordinate = $data["-2"];
$this->yCoordinate = $data["-3"];
}
}
public static function fromJson($jsonData): PublicKey {
$publicKey = new PublicKey(null);
$publicKey->keyType = $jsonData["keyType"];
$publicKey->usedAlgorithm = $jsonData["usedAlgorithm"];
$publicKey->curveType = $jsonData["curveType"];
$publicKey->xCoordinate = base64_decode($jsonData["coordinates"]["x"]);
$publicKey->yCoordinate = base64_decode($jsonData["coordinates"]["y"]);
return $publicKey;
}
public function getUsedAlgorithm(): int {
return $this->usedAlgorithm;
}
public function jsonSerialize(): array {
return [
"keyType" => $this->keyType,
"usedAlgorithm" => $this->usedAlgorithm,
"curveType" => $this->curveType,
"coordinates" => [
"x" => base64_encode($this->xCoordinate),
"y" => base64_encode($this->yCoordinate)
],
];
}
public function getNormalizedData(): array {
return [
"1" => $this->keyType,
"3" => $this->usedAlgorithm,
"-1" => $this->curveType,
"-2" => $this->xCoordinate,
"-3" => $this->yCoordinate,
];
}
public function getU2F(): string {
return bin2hex("\x04" . $this->xCoordinate . $this->yCoordinate);
}
}

@ -0,0 +1,59 @@
<?php
namespace Objects\TwoFactor;
use Base32\Base32;
use chillerlan\QRCode\QRCode;
use chillerlan\QRCode\QROptions;
use Objects\User;
class TimeBasedTwoFactorToken extends TwoFactorToken {
const TYPE = "totp";
private string $secret;
public function __construct(string $secret, ?int $id = null, bool $confirmed = false) {
parent::__construct(self::TYPE, $id, $confirmed);
$this->secret = $secret;
}
public function getUrl(User $user): string {
$otpType = self::TYPE;
$name = rawurlencode($user->getUsername());
$settings = $user->getConfiguration()->getSettings();
$urlArgs = [
"secret" => $this->secret,
"issuer" => $settings->getSiteName(),
];
$urlArgs = http_build_query($urlArgs);
return "otpauth://$otpType/$name?$urlArgs";
}
public function generateQRCode(User $user) {
$options = new QROptions(['outputType' => QRCode::OUTPUT_IMAGE_PNG, "imageBase64" => false]);
$qrcode = new QRCode($options);
return $qrcode->render($this->getUrl($user));
}
public function generate(?int $at = null, int $length = 6, int $period = 30): string {
if ($at === null) {
$at = time();
}
$seed = intval($at / $period);
$secret = Base32::decode($this->secret);
$hmac = hash_hmac('sha1', pack("J", $seed), $secret, true);
$offset = ord($hmac[-1]) & 0xF;
$code = (unpack("N", substr($hmac, $offset, 4))[1] & 0x7fffffff) % intval(pow(10, $length));
return substr(str_pad(strval($code), $length, "0", STR_PAD_LEFT), -1 * $length);
}
public function verify(string $code): bool {
return $this->generate() === $code;
}
public function getData(): string {
return $this->secret;
}
}

@ -0,0 +1,62 @@
<?php
namespace Objects\TwoFactor;
use Objects\ApiObject;
abstract class TwoFactorToken extends ApiObject {
private ?int $id;
private string $type;
private bool $confirmed;
private bool $authenticated;
public function __construct(string $type, ?int $id = null, bool $confirmed = false) {
$this->id = $id;
$this->type = $type;
$this->confirmed = $confirmed;
$this->authenticated = $_SESSION["2faAuthenticated"] ?? false;
}
public function jsonSerialize(): array {
return [
"id" => $this->id,
"type" => $this->type,
"confirmed" => $this->confirmed,
"authenticated" => $this->authenticated,
];
}
public abstract function getData(): string;
public function authenticate() {
$this->authenticated = true;
$_SESSION["2faAuthenticated"] = true;
}
public function getType(): string {
return $this->type;
}
public function isConfirmed(): bool {
return $this->confirmed;
}
public function getId(): int {
return $this->id;
}
public static function newInstance(string $type, string $data, ?int $id = null, bool $confirmed = false) {
if ($type === TimeBasedTwoFactorToken::TYPE) {
return new TimeBasedTwoFactorToken($data, $id, $confirmed);
} else if ($type === KeyBasedTwoFactorToken::TYPE) {
return new KeyBasedTwoFactorToken($data, $id, $confirmed);
} else {
// TODO: error message
return null;
}
}
public function isAuthenticated(): bool {
return $this->authenticated;
}
}

@ -0,0 +1,63 @@
<?php
namespace Objects;
abstract class TwoFactorToken extends ApiObject {
private ?int $id;
private string $type;
private string $secret;
private bool $confirmed;
private bool $authenticated;
public function __construct(string $type, string $secret, ?int $id = null, bool $confirmed = false) {
$this->id = $id;
$this->type = $type;
$this->secret = $secret;
$this->confirmed = $confirmed;
$this->authenticated = $_SESSION["2faAuthenticated"] ?? false;
}
public function jsonSerialize(): array {
return [
"id" => $this->id,
"type" => $this->type,
"confirmed" => $this->confirmed,
"authenticated" => $this->authenticated,
];
}
public function authenticate() {
$this->authenticated = true;
$_SESSION["2faAuthenticated"] = true;
}
public function getType(): string {
return $this->type;
}
public function getSecret(): string {
return $this->secret;
}
public function isConfirmed(): bool {
return $this->confirmed;
}
public function getId(): int {
return $this->id;
}
public static function newInstance(string $type, string $secret, ?int $id = null, bool $confirmed = false) {
if ($type === TimeBasedTwoFactorToken::TYPE) {
return new TimeBasedTwoFactorToken($secret, $id, $confirmed);
} else {
// TODO: error message
return null;
}
}
public function isAuthenticated(): bool {
return $this->authenticated;
}
}

@ -8,7 +8,9 @@ use External\JWT;
use Driver\SQL\SQL;
use Driver\SQL\Condition\Compare;
use Driver\SQL\Condition\CondBool;
use Objects\TwoFactor\TwoFactorToken;
// TODO: User::authorize and User::readData have similar function body
class User extends ApiObject {
private ?SQL $sql;
@ -22,6 +24,8 @@ class User extends ApiObject {
private ?string $profilePicture;
private Language $language;
private array $groups;
private ?GpgKey $gpgKey;
private ?TwoFactorToken $twoFactorToken;
public function __construct($configuration) {
$this->configuration = $configuration;
@ -66,6 +70,8 @@ class User extends ApiObject {
public function getConfiguration(): Configuration { return $this->configuration; }
public function getGroups(): array { return $this->groups; }
public function hasGroup(int $group): bool { return isset($this->groups[$group]); }
public function getGPG(): ?GpgKey { return $this->gpgKey; }
public function getTwoFactorToken(): ?TwoFactorToken { return $this->twoFactorToken; }
public function getProfilePicture() : ?string { return $this->profilePicture; }
public function __debugInfo(): array {
@ -93,6 +99,8 @@ class User extends ApiObject {
'groups' => $this->groups,
'language' => $this->language->jsonSerialize(),
'session' => $this->session->jsonSerialize(),
"gpg" => ($this->gpgKey ? $this->gpgKey->jsonSerialize() : null),
"2fa" => ($this->twoFactorToken ? $this->twoFactorToken->jsonSerialize() : null),
);
} else {
return array(
@ -109,11 +117,13 @@ class User extends ApiObject {
$this->loggedIn = false;
$this->session = null;
$this->profilePicture = null;
$this->gpgKey = null;
$this->twoFactorToken = null;
}
public function logout(): bool {
$success = true;
if($this->loggedIn) {
if ($this->loggedIn) {
$success = $this->session->destroy();
$this->reset();
}
@ -131,11 +141,15 @@ class User extends ApiObject {
}
public function sendCookies() {
if($this->loggedIn) {
$this->session->sendCookie();
$baseUrl = $this->getConfiguration()->getSettings()->getBaseUrl();
$domain = parse_url($baseUrl, PHP_URL_HOST);
if ($this->loggedIn) {
$this->session->sendCookie($domain);
}
$this->language->sendCookie();
$this->language->sendCookie($domain);
session_write_close();
}
@ -147,7 +161,11 @@ class User extends ApiObject {
*/
public function readData($userId, $sessionId, bool $sessionUpdate = true): bool {
$res = $this->sql->select("User.name", "User.email", "User.fullName", "User.profilePicture",
$res = $this->sql->select("User.name", "User.email", "User.fullName",
"User.profilePicture",
"User.gpg_id", "GpgKey.confirmed as gpg_confirmed", "GpgKey.fingerprint as gpg_fingerprint",
"GpgKey.expires as gpg_expires", "GpgKey.algorithm as gpg_algorithm",
"User.2fa_id", "2FA.confirmed as 2fa_confirmed", "2FA.data as 2fa_data", "2FA.type as 2fa_type",
"Language.uid as langId", "Language.code as langCode", "Language.name as langName",
"Session.data", "Session.stay_logged_in", "Session.csrf_token", "Group.uid as groupId", "Group.name as groupName")
->from("User")
@ -155,6 +173,8 @@ class User extends ApiObject {
->leftJoin("Language", "User.language_id", "Language.uid")
->leftJoin("UserGroup", "UserGroup.user_id", "User.uid")
->leftJoin("Group", "UserGroup.group_id", "Group.uid")
->leftJoin("GpgKey", "User.gpg_id", "GpgKey.uid")
->leftJoin("2FA", "User.2fa_id", "2FA.uid")
->where(new Compare("User.uid", $userId))
->where(new Compare("Session.uid", $sessionId))
->where(new Compare("Session.active", true))
@ -175,11 +195,21 @@ class User extends ApiObject {
$this->profilePicture = $row["profilePicture"];
$this->session = new Session($this, $sessionId, $csrfToken);
$this->session->setData(json_decode($row["data"] ?? '{}'));
$this->session->stayLoggedIn($this->sql->parseBool(["stay_logged_in"]));
if($sessionUpdate) $this->session->update();
$this->session->setData(json_decode($row["data"] ?? '{}', true));
$this->session->stayLoggedIn($this->sql->parseBool($row["stay_logged_in"]));
if ($sessionUpdate) $this->session->update();
$this->loggedIn = true;
if (!empty($row["gpg_id"])) {
$this->gpgKey = new GpgKey($row["gpg_id"], $this->sql->parseBool($row["gpg_confirmed"]),
$row["gpg_fingerprint"], $row["gpg_algorithm"], $row["gpg_expires"]);
}
if (!empty($row["2fa_id"])) {
$this->twoFactorToken = TwoFactorToken::newInstance($row["2fa_type"], $row["2fa_data"],
$row["2fa_id"], $this->sql->parseBool($row["2fa_confirmed"]));
}
if(!is_null($row['langId'])) {
$this->setLanguage(Language::newInstance($row['langId'], $row['langCode'], $row['langName']));
}
@ -194,7 +224,7 @@ class User extends ApiObject {
}
private function parseCookies() {
if(isset($_COOKIE['session']) && is_string($_COOKIE['session']) && !empty($_COOKIE['session'])) {
if (isset($_COOKIE['session']) && is_string($_COOKIE['session']) && !empty($_COOKIE['session'])) {
try {
$token = $_COOKIE['session'];
$settings = $this->configuration->getSettings();
@ -218,7 +248,7 @@ class User extends ApiObject {
}
}
public function createSession($userId, $stayLoggedIn): bool {
public function createSession(int $userId, bool $stayLoggedIn = false): bool {
$this->uid = $userId;
$this->session = Session::create($this, $stayLoggedIn);
if ($this->session) {
@ -237,6 +267,9 @@ class User extends ApiObject {
$res = $this->sql->select("ApiKey.user_id as uid", "User.name", "User.fullName", "User.email",
"User.confirmed", "User.profilePicture",
"User.gpg_id", "GpgKey.fingerprint as gpg_fingerprint", "GpgKey.expires as gpg_expires",
"GpgKey.confirmed as gpg_confirmed", "GpgKey.algorithm as gpg_algorithm",
"User.2fa_id", "2FA.confirmed as 2fa_confirmed", "2FA.data as 2fa_data", "2FA.type as 2fa_type",
"Language.uid as langId", "Language.code as langCode", "Language.name as langName",
"Group.uid as groupId", "Group.name as groupName")
->from("ApiKey")
@ -244,6 +277,8 @@ class User extends ApiObject {
->leftJoin("UserGroup", "UserGroup.user_id", "User.uid")
->leftJoin("Group", "UserGroup.group_id", "Group.uid")
->leftJoin("Language", "User.language_id", "Language.uid")
->leftJoin("GpgKey", "User.gpg_id", "GpgKey.uid")
->leftJoin("2FA", "User.2fa_id", "2FA.uid")
->where(new Compare("ApiKey.api_key", $apiKey))
->where(new Compare("valid_until", $this->sql->currentTimestamp(), ">"))
->where(new Compare("ApiKey.active", 1))
@ -265,6 +300,17 @@ class User extends ApiObject {
$this->email = $row['email'];
$this->profilePicture = $row["profilePicture"];
if (!empty($row["gpg_id"])) {
$this->gpgKey = new GpgKey($row["gpg_id"], $this->sql->parseBool($row["gpg_confirmed"]),
$row["gpg_fingerprint"], $row["gpg_algorithm"], $row["gpg_expires"]
);
}
if (!empty($row["2fa_id"])) {
$this->twoFactorToken = TwoFactorToken::newInstance($row["2fa_type"], $row["2fa_data"],
$row["2fa_id"], $this->sql->parseBool($row["2fa_confirmed"]));
}
if(!is_null($row['langId'])) {
$this->setLanguage(Language::newInstance($row['langId'], $row['langCode'], $row['langName']));
}

@ -0,0 +1,16 @@
{% extends "base.twig" %}
{% block head %}
<script src="/js/jquery.min.js" nonce="{{ site.csp.nonce }}"></script>
<script src="/js/script.js" nonce="{{ site.csp.nonce }}"></script>
<script src="/js/account.js" nonce="{{ site.csp.nonce }}"></script>
<link rel="stylesheet" href="/css/bootstrap.min.css" nonce="{{ site.csp.nonce }}">
<script src="/js/bootstrap.bundle.min.js" nonce="{{ site.csp.nonce }}"></script>
<link rel="stylesheet" href="/css/fontawesome.min.css" nonce="{{ site.csp.nonce }}">
<link rel="stylesheet" href="/css/account.css" nonce="{{ site.csp.nonce }}">
<title>Account - {{ title }}</title>
{% endblock %}
{% block body %}
{% endblock %}

@ -2,7 +2,7 @@
{% block head %}
<title>{{ site.name }} - Administration</title>
<script src="/js/fontawesome-all.min.js" nonce="{{ site.csp.nonce }}"></script>
<link rel="stylesheet" href="/css/fontawesome.min.css" nonce="{{ site.csp.nonce }}">
{% endblock %}
{% block body %}

13
core/Templates/base.html Normal file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="{{ user.lang }}">
<head>
<meta charset="utf-8" />
{% block head %}
<title>{{ site.title }}</title>
{% endblock %}
</head>
<body>
{% block body %}
{% endblock %}
</body>
</html>

@ -0,0 +1,6 @@
Hello {{ username }},<br><br>
this is a notification, that 2FA-Authorization was removed from your account. If this was intended, you can simply ignore this email.<br>
Otherwise contact the <a href="mailto:{{ sender_mail }}">Administration</a> immediately!<br><br>
Best Regards<br>
{{ site_name }} Administration

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

@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Swagger UI</title>
<link nonce="{{ site.csp.nonce }}" rel="stylesheet" type="text/css" href="/css/swagger-ui.css" />
<style nonce="{{ site.csp.nonce }}">
html {
box-sizing: border-box;
overflow: -moz-scrollbars-vertical;
overflow-y: scroll;
}
*, *:before, *:after {
box-sizing: inherit;
}
body {
margin:0;
background: #fafafa;
}
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script nonce="{{ site.csp.nonce }}" src="/js/swagger-ui-bundle.js" charset="UTF-8"></script>
<script nonce="{{ site.csp.nonce }}" src="/js/swagger-ui-standalone-preset.js" charset="UTF-8"></script>
<script nonce="{{ site.csp.nonce }}">
window.onload = function() {
// Begin Swagger UI call region
window.ui = SwaggerUIBundle({
url: "{{ site.baseUrl }}/api/swagger",
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl
],
layout: "StandaloneLayout",
{% if user.loggedIn %}
requestInterceptor: request => {
request.headers['XSRF-Token'] = '{{ user.session.csrfToken }}';
return request;
}
});
{% endif %}
};
</script>
</body>
</html>

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

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

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

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

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

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

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

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

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

@ -2,17 +2,21 @@
require_once "External/vendor/autoload.php";
define("WEBBASE_VERSION", "1.3.0-beta");
define("WEBBASE_VERSION", "1.4.0");
spl_autoload_extensions(".php");
spl_autoload_register(function($class) {
if (!class_exists($class)) {
$full_path = WEBROOT . "/" . getClassPath($class);
if (file_exists($full_path)) {
include_once $full_path;
} else {
include_once getClassPath($class, false);
$suffixes = ["", ".class", ".trait"];
foreach ($suffixes as $suffix) {
$full_path = WEBROOT . "/" . getClassPath($class, $suffix);
if (file_exists($full_path)) {
include_once $full_path;
return;
}
}
throw new Exception("Class or Trait not found: $class");
}
});
@ -50,11 +54,13 @@ function generateRandomString($length, $type = "ascii"): string {
$charset = $hex;
} else if ($type === "base64") {
$charset = $ascii . "/+";
} else if ($type === "base32") {
$charset = $uppercase . substr($digits, 2, 6);
} else {
$charset = $ascii;
}
$numCharacters = strlen($charset);
$numCharacters = $type === "raw" ? 256 : strlen($charset);
for ($i = 0; $i < $length; $i++) {
try {
$num = random_int(0, $numCharacters - 1);
@ -62,13 +68,18 @@ function generateRandomString($length, $type = "ascii"): string {
$num = rand(0, $numCharacters - 1);
}
$randomString .= $charset[$num];
$randomString .= $type === "raw" ? chr($num) : $charset[$num];
}
}
return $randomString;
}
function base64url_decode($data) {
$base64 = strtr($data, '-_', '+/');
return base64_decode($base64);
}
function startsWith($haystack, $needle, bool $ignoreCase = false): bool {
$length = strlen($needle);
@ -109,6 +120,27 @@ function endsWith($haystack, $needle, bool $ignoreCase = false): bool {
}
}
function contains($haystack, $needle, bool $ignoreCase = false): bool {
if (is_array($haystack)) {
return in_array($needle, $haystack);
}
if ($ignoreCase) {
$haystack = strtolower($haystack);
$needle = strtolower($needle);
}
// PHP 8.0 support
if (function_exists("str_contains")) {
return str_contains($haystack, $needle);
} else {
return strpos($haystack, $needle) !== false;
}
}
function intendCode($code, $escape = true) {
$newCode = "";
$first = true;
@ -156,7 +188,7 @@ function html_attributes(array $attributes): string {
}, array_keys($attributes)));
}
function getClassPath($class, $suffix = true): string {
function getClassPath($class, string $suffix = ".class"): string {
$path = str_replace('\\', '/', $class);
$path = array_values(array_filter(explode("/", $path)));
@ -166,7 +198,6 @@ function getClassPath($class, $suffix = true): string {
$path = implode("/", $path);
}
$suffix = ($suffix ? ".class" : "");
return "core/$path$suffix.php";
}
@ -174,6 +205,26 @@ function createError($msg) {
return json_encode(array("success" => false, "msg" => $msg));
}
function downloadFile($handle, $offset = 0, $length = null): bool {
if($handle === false) {
return false;
}
if ($offset > 0) {
fseek($handle, $offset);
}
$bytesRead = 0;
$bufferSize = 1024*16;
while (!feof($handle) && ($length === null || $bytesRead < $length)) {
$chunkSize = ($length === null ? $bufferSize : min($length - $bytesRead, $bufferSize));
echo fread($handle, $chunkSize);
}
fclose($handle);
return true;
}
function serveStatic(string $webRoot, string $file): string {
$path = realpath($webRoot . "/" . $file);
@ -204,7 +255,6 @@ function serveStatic(string $webRoot, string $file): string {
header('Accept-Ranges: bytes');
if (strcasecmp($_SERVER["REQUEST_METHOD"], "HEAD") !== 0) {
$bufferSize = 1024*16;
$handle = fopen($path, "rb");
if($handle === false) {
http_response_code(500);
@ -222,17 +272,7 @@ function serveStatic(string $webRoot, string $file): string {
header('Content-Range: bytes ' . $offset . '-' . ($offset + $length) . '/' . $size);
}
if ($offset > 0) {
fseek($handle, $offset);
}
$bytesRead = 0;
while (!feof($handle) && $bytesRead < $length) {
$chunkSize = min($length - $bytesRead, $bufferSize);
echo fread($handle, $chunkSize);
}
fclose($handle);
downloadFile($handle, $offset, $length);
}
return "";

4
css/swagger-ui.css Normal file

File diff suppressed because one or more lines are too long

@ -8,7 +8,7 @@ server {
rewrite ^/api(/.*)$ /index.php?api=$1;
# deny access to .gitignore / .htaccess
location ~ /\. {
location ~ /\.(?!well-known).* {
rewrite ^(.*)$ /index.php?site=$1;
}
@ -18,7 +18,7 @@ server {
}
# deny access to specific directories
location ~ ^/(files/uploaded|adminPanel|fileControlPanel|docker|core|test)/.*$ {
location ~ ^/(files/uploaded|adminPanel|docker|core|test)/.*$ {
rewrite ^(.*)$ /index.php?site=$1;
}

@ -1,3 +0,0 @@
{
"presets": ["@babel/preset-env", "@babel/preset-react"]
}

@ -1,24 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
public/
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

@ -1 +0,0 @@
DENY FROM ALL

@ -1,5 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

@ -1,6 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="WebPackConfiguration">
<option name="mode" value="DISABLED" />
</component>
</project>

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/fileControlPanel.iml" filepath="$PROJECT_DIR$/.idea/fileControlPanel.iml" />
</modules>
</component>
</project>

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>

File diff suppressed because it is too large Load Diff

@ -1,46 +0,0 @@
{
"name": "file-control-panel",
"version": "0.1.0",
"private": true,
"dependencies": {
"axios": "^0.21.1",
"moment": "^2.26.0",
"react": "^16.13.1",
"react-collapse": "^5.0.1",
"react-dom": "^16.13.1",
"react-draft-wysiwyg": "^1.14.5",
"react-dropzone": "^11.2.4",
"react-router-dom": "^5.2.0",
"react-scripts": "^4.0.3",
"react-tooltip": "^4.2.13"
},
"scripts": {
"build": "webpack --mode production && mv dist/main.js ../js/files.min.js",
"debug": "react-scripts start"
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"proxy": "http://localhost",
"devDependencies": {
"@babel/core": "^7.10.2",
"@babel/preset-env": "^7.10.2",
"@babel/preset-react": "^7.10.1",
"babel-loader": "^8.1.0",
"babel-polyfill": "^6.26.0",
"webpack": "^4.42.0",
"webpack-cli": "^4.3.1"
}
}

@ -1,96 +0,0 @@
import 'babel-polyfill';
import axios from "axios";
export default class API {
constructor() {
this.loggedIn = false;
this.user = { };
}
csrfToken() {
return this.loggedIn ? this.user.session.csrf_token : null;
}
async apiCall(method, params) {
params = params || { };
const csrf_token = this.csrfToken();
if (csrf_token) params.csrf_token = csrf_token;
let response = await axios.post("/api/" + method, params);
return response.data;
}
async fetchUser() {
let response = await axios.get("/api/user/info");
let data = response.data;
this.user = data["user"];
this.loggedIn = data["loggedIn"];
return data && data["success"] && data["loggedIn"];
}
async logout() {
return this.apiCall("user/logout");
}
validateToken(token) {
return this.apiCall("file/validateToken", { token: token });
}
listFiles() {
return this.apiCall("file/listFiles");
}
listTokens() {
return this.apiCall("file/listTokens");
}
delete(id, token=null) {
return this.apiCall("file/delete", { id: id, token: token });
}
revokeToken(token) {
return this.apiCall("file/revokeToken", { token: token });
}
createDownloadToken(durability, files) {
return this.apiCall("file/createDownloadToken", { files: files, durability: durability });
}
createUploadToken(durability, parentId=null, maxFiles=0, maxSize=0, extensions = "") {
return this.apiCall("file/createUploadToken", { parentId: parentId, durability: durability, maxFiles: maxFiles, maxSize: maxSize, extensions: extensions });
}
createDirectory(name, parentId = null) {
return this.apiCall("file/createDirectory", { name: name, parentId: parentId });
}
moveFiles(files, parentId = null) {
return this.apiCall("file/move", { id: files, parentId: parentId });
}
rename(id, name, token = null) {
return this.apiCall("file/rename", { id: id, name: name, token: token });
}
getRestrictions() {
return this.apiCall("file/getRestrictions");
}
async upload(file, token = null, parentId = null, cancelToken = null, onUploadProgress = null) {
const csrf_token = this.csrfToken();
const fd = new FormData();
fd.append("file", file);
if (csrf_token) fd.append("csrf_token", csrf_token);
if (token) fd.append("token", token);
if (parentId) fd.append("parentId", parentId);
let response = await axios.post('/api/file/upload', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: onUploadProgress || function () { },
cancelToken : cancelToken.token
});
return response.data;
}
};

@ -1,25 +0,0 @@
import Icon from "./icon";
import React from "react";
export default function Alert(props) {
const onClose = props.onClose || null;
const title = props.title || "Untitled Alert";
const message = props.message || "Alert message";
const type = props.type || "danger";
let icon = "ban";
if (type === "warning") {
icon = "exclamation-triangle";
} else if(type === "success") {
icon = "check";
}
return (
<div className={"alert alert-" + type + " alert-dismissible"}>
{onClose ? <button type="button" className={"close"} data-dismiss={"alert"} aria-hidden={"true"} onClick={onClose}>×</button> : null}
<h5><Icon icon={icon} className={"icon"} /> {title}</h5>
{message}
</div>
)
}

@ -1,85 +0,0 @@
.file-row td {
padding: 0;
border: none;
vertical-align: middle;
font-size: 0.9em;
}
.file-control-buttons {
display: grid;
grid-template-rows: auto auto;
grid-template-columns: auto auto;
}
.file-control-buttons > button {
margin: 15px;
}
.file-upload-container {
border: dotted;
margin: 18px;
padding: 15px;
min-height: 150px;
text-align: center;
cursor: pointer;
}
.file-upload-container > div > div {
display: grid;
grid-template-columns: auto auto auto auto;
}
.uploaded-file {
max-width: 120px;
position: relative;
margin-bottom: 15px;
}
.uploaded-file > span {
display: block;
word-wrap: break-word;
}
.uploaded-file > .status-icon {
position: absolute;
top: -9px;
right: 25px;
cursor: pointer;
}
.uploaded-file > .cancel-button {
position: absolute;
left: 0;
right: 0;
top: 15px;
bottom: 0;
opacity: 0;
}
.uploaded-file:hover > .file-icon {
opacity: 0.5;
}
.uploaded-file:hover > .cancel-button {
opacity: 1.0;
}
.clickable { cursor: pointer; }
.token-revoked td { text-decoration: line-through; }
.token-table td:not(:first-child), .token-table th:not(:first-child) {
text-align: center;
}
.token-table td:nth-child(4) > i {
padding-left: 10px;
}
.file-table td:nth-child(n+3), .file-table th:nth-child(n+3) {
text-align: center;
}
.file-browser-restrictions {
display: grid;
grid-template-columns: repeat(4, auto);
}

@ -1,630 +0,0 @@
import * as React from "react";
import "./file-browser.css";
import Dropzone from "react-dropzone";
import Icon from "./icon";
import Alert from "./alert";
import {Popup} from "./popup";
import {useEffect, useState} from "react";
import axios from "axios";
export function FileBrowser(props) {
let files = props.files || {};
let api = props.api;
let tokenObj = props.token || {valid: false};
let onSelectFile = props.onSelectFile || function () { };
let onFetchFiles = props.onFetchFiles || function () { };
let directories = props.directories || {};
let restrictions = props.restrictions || {maxFiles: 0, maxSize: 0, extensions: ""};
let [popup, setPopup] = useState({ visible: false, name: "", directory: 0, target: null, type: "createDirectory" });
let [alerts, setAlerts] = useState([]);
let [filesToUpload, setFilesToUpload] = useState([]);
function canUpload() {
return api.loggedIn || (tokenObj.valid && tokenObj.type === "upload");
}
function svgMiddle(key, scale = 1.0) {
let width = 48 * scale;
let height = 64 * scale;
return <svg key={key} width={width} height={height} xmlns="http://www.w3.org/2000/svg">
<g>
<line y2="0" x2={width / 2} y1={height} x1={width / 2} strokeWidth="1.5" stroke="#000" fill="none"/>
<line y2={height / 2} x2={width} y1={height / 2} x1={width / 2} strokeWidth="1.5" stroke="#000"
fill="none"/>
</g>
</svg>;
}
function svgEnd(key, scale = 1.0) {
let width = 48 * scale;
let height = 64 * scale;
return <svg key={key} width={width} height={height} xmlns="http://www.w3.org/2000/svg">
<g>
{ /* vertical line */}
<line y2="0" x2={width / 2} y1={height / 2} x1={width / 2} strokeWidth="1.5" stroke="#000" fill="none"/>
{ /* horizontal line */}
<line y2={height / 2} x2={width} y1={height / 2} x1={width / 2} strokeWidth="1.5" stroke="#000"
fill="none"/>
</g>
</svg>;
}
function svgLeft(key, scale = 1.0) {
let width = 48 * scale;
let height = 64 * scale;
return <svg key={key} width={width} height={height} xmlns="http://www.w3.org/2000/svg">
<g>
{ /* vertical line */}
<line y2="0" x2={width / 2} y1={height} x1={width / 2} strokeWidth="1.5" stroke="#000" fill="none"/>
</g>
</svg>;
}
function createFileIcon(mimeType, size = "2x") {
let icon = "";
if (mimeType) {
mimeType = mimeType.toLowerCase().trim();
let types = ["image", "text", "audio", "video"];
let languages = ["php", "java", "python", "cpp"];
let archives = ["zip", "tar", "archive"];
let [mainType, subType] = mimeType.split("/");
if (mainType === "text" && languages.find(a => subType.includes(a))) {
icon = "code";
} else if (mainType === "application" && archives.find(a => subType.includes(a))) {
icon = "archive";
} else if (mainType === "application" && subType === "pdf") {
icon = "pdf";
} else if (mainType === "application" && (subType.indexOf("powerpoint") > -1 || subType.indexOf("presentation") > -1)) {
icon = "powerpoint";
} else if (mainType === "application" && (subType.indexOf("word") > -1 || subType.indexOf("opendocument") > -1)) {
icon = "word";
} else if (mainType === "application" && (subType.indexOf("excel") > -1 || subType.indexOf("sheet") > -1)) {
icon = "excel";
} else if (mainType === "application" && subType.indexOf("directory") > -1) {
icon = "folder";
} else if (types.indexOf(mainType) > -1) {
if (mainType === "text") {
icon = "alt";
} else {
icon = mainType;
}
}
}
if (icon !== "folder") {
icon = "file" + (icon ? ("-" + icon) : icon);
}
return <Icon icon={icon} type={"far"} className={"p-1 align-middle file-icon fa-" + size}/>
}
function formatSize(size) {
const suffixes = ["B", "KiB", "MiB", "GiB", "TiB"];
let i = 0;
for (; i < suffixes.length && size >= 1024; i++) {
size /= 1024.0;
}
if (i === 0 || Math.round(size) === size) {
return size + " " + suffixes[i];
} else {
return size.toFixed(1) + " " + suffixes[i];
}
}
useEffect(() => {
let newFiles = filesToUpload.slice();
for (let fileIndex = 0; fileIndex < newFiles.length; fileIndex++) {
if (typeof newFiles[fileIndex].progress === 'undefined') {
onUpload(fileIndex);
break;
}
}
}, [filesToUpload]);
function onAddUploadFiles(acceptedFiles, rejectedFiles) {
if (rejectedFiles && rejectedFiles.length > 0) {
const filenames = rejectedFiles.map(f => f.file.name).join(", ");
pushAlert({msg: "The following files could not be uploaded due to given restrictions: " + filenames }, "Cannot upload file");
}
if (acceptedFiles && acceptedFiles.length > 0) {
let files = filesToUpload.slice();
files.push(...acceptedFiles);
setFilesToUpload(files);
}
}
function getSelectedIds(items = null, recursive = true) {
let ids = [];
items = items || files;
for (const fileItem of Object.values(items)) {
if (fileItem.selected) {
ids.push(fileItem.uid);
}
if (recursive && fileItem.isDirectory) {
ids.push(...getSelectedIds(fileItem.items));
}
}
return ids;
}
// TODO: add more mime type names or use an directory here?
function getTypeName(type) {
if (type.toLowerCase() === "directory") {
return "Directory";
}
switch (type.toLowerCase()) {
case "image/jpeg":
return "JPEG-Image";
case "image/png":
return "PNG-Image";
case "application/pdf":
return "PDF-Document";
case "text/plain":
return "Text-Document"
case "application/x-dosexec":
return "Windows Executable";
case "application/vnd.oasis.opendocument.text":
return "OpenOffice-Document";
default:
return type;
}
}
let selectedIds = getSelectedIds();
let selectedCount = selectedIds.length;
let uploadZone = <></>;
let writePermissions = canUpload();
let uploadedFiles = [];
let alertElements = [];
function createFileList(elements, indentation = 0) {
let rows = [];
let rowIndex = 0;
const scale = 0.45;
const iconSize = "lg";
const values = Object.values(elements);
for (const fileElement of values) {
let name = fileElement.name;
let uid = fileElement.uid;
let type = (fileElement.isDirectory ? "Directory" : fileElement.mimeType);
let size = (fileElement.isDirectory ? "" : formatSize(fileElement.size));
let mimeType = (fileElement.isDirectory ? "application/x-directory" : fileElement.mimeType);
let token = (tokenObj && tokenObj.valid ? "&token=" + tokenObj.value : "");
let svg = [];
if (indentation > 0) {
for (let i = 0; i < indentation - 1; i++) {
svg.push(svgLeft(rowIndex + "-" + i, scale));
}
if (rowIndex === values.length - 1) {
svg.push(svgEnd(rowIndex + "-end", scale));
} else {
svg.push(svgMiddle(rowIndex + "-middle", scale));
}
}
rows.push(
<tr key={"file-" + uid} data-id={uid} className={"file-row"}>
<td>
{svg}
{createFileIcon(mimeType, iconSize)}
</td>
<td>
{fileElement.isDirectory ? name :
<a href={"/api/file/download?id=" + uid + token} download={true}>{name}</a>
}
</td>
<td>{getTypeName(type)}</td>
<td>{size}</td>
<td>
<input type={"checkbox"} checked={!!fileElement.selected}
onChange={(e) => onSelectFile(e, uid)}
/>
{ writePermissions ?
<Icon icon={"pencil-alt"} title={"Rename"} className={"ml-2 clickable text-secondary"}
style={{marginTop: "-17px"}} onClick={() => onPopupOpen("rename", uid)} /> :
<></> }
</td>
</tr>
);
if (fileElement.isDirectory) {
rows.push(...createFileList(fileElement.items, indentation + 1));
}
rowIndex++;
}
return rows;
}
for (let i = 0; i < alerts.length; i++) {
const alert = alerts[i];
alertElements.push(
<Alert key={"alert-" + i} {...alert} onClose={() => removeAlert(i)}/>
);
}
let options = [];
for (const [uid, dir] of Object.entries(directories)) {
options.push(
<option key={"option-" + dir} value={uid}>{dir}</option>
);
}
function getAllowedExtensions() {
let extensions = restrictions.extensions || "";
return extensions.split(",")
.map(ext => ext.trim())
.map(ext => !ext.startsWith(".") && ext.length > 0 ? "." + ext : ext)
.join(",");
}
function getRestrictions() {
return {
accept: getAllowedExtensions(),
maxFiles: restrictions.maxFiles,
maxSize: restrictions.maxSize
};
}
function onCancelUpload(e, i) {
e.stopPropagation();
e.preventDefault();
const cancelToken = filesToUpload[i].cancelToken;
if (cancelToken && filesToUpload[i].progress < 1) {
cancelToken.cancel("Upload cancelled");
}
let files = filesToUpload.slice();
files.splice(i, 1);
setFilesToUpload(files);
}
let rows = createFileList(files);
if (writePermissions) {
for (let i = 0; i < filesToUpload.length; i++) {
const file = filesToUpload[i];
const progress = Math.round((file.progress ?? 0) * 100);
const done = progress >= 100;
uploadedFiles.push(
<span className={"uploaded-file"} key={i}>
{createFileIcon(file.type, "3x")}
<span>{file.name}</span>
{!done ?
<div className={"progress border border-primary position-relative"}>
<div className={"progress-bar progress-bar-striped progress-bar-animated"} role={"progressbar"}
aria-valuenow={progress} aria-valuemin={"0"} aria-valuemax={"100"}
style={{width: progress + "%"}} />
<span className="justify-content-center d-flex position-absolute w-100" style={{top: "7px"}}>
{ progress + "%" }
</span>
</div> : <></>
}
<Icon icon={done ? (file.success ? "check" : "times") : "spinner"}
className={"status-icon " + (done ? (file.success ? "text-success" : "text-danger") : "text-secondary")} />
<Icon icon={"times"} className={"text-danger cancel-button fa-2x"}
title={"Cancel Upload"} onClick={(e) => onCancelUpload(e, i)}/>
</span>
);
}
uploadZone = <>
<div className={"p-3"}>
<label><b>Upload Directory:</b></label>
<select value={popup.directory} className={"form-control"}
onChange={(e) => onPopupChange(e, "directory")}>
{options}
</select>
</div>
<Dropzone onDrop={onAddUploadFiles} {...getRestrictions()} >
{({getRootProps, getInputProps}) => (
<section className={"file-upload-container"}>
<div {...getRootProps()}>
<input {...getInputProps()} />
<p>Drag 'n' drop some files here, or click to select files</p>
{uploadedFiles.length === 0 ?
<Icon className={"mx-auto fa-3x text-black-50"} icon={"upload"}/> :
<div>{uploadedFiles}</div>
}
</div>
</section>
)}
</Dropzone>
</>;
}
let singleButton = {
gridColumnStart: 1,
gridColumnEnd: 3,
width: "40%",
margin: "0 auto"
};
function createPopup() {
let title = "";
let inputs = [];
if (popup.type === "createDirectory" || popup.type === "moveFiles") {
inputs.push(
<div className={"form-group"} key={"select-directory"}>
<label>Destination Directory:</label>
<select value={popup.directory} className={"form-control"}
onChange={(e) => onPopupChange(e, "directory")}>
{options}
</select>
</div>
);
}
if (popup.type === "createDirectory" || popup.type === "rename") {
inputs.push(
<div className={"form-group"} key={"input-name"}>
<label>{ popup.type === "createDirectory" ? "Create Directory" : "New Name" }</label>
<input type={"text"} className={"form-control"} value={popup.name} maxLength={32}
placeholder={"Enter name…"}
onChange={(e) => onPopupChange(e, "name")}/>
</div>
);
}
if (popup.type === "createDirectory") {
title = "Create Directory";
} else if (popup.type === "moveFiles") {
title = "Move Files";
} else if (popup.type === "rename") {
title = "Rename File or Directory";
}
return <Popup title={title} visible={popup.visible} buttons={["Ok", "Cancel"]} onClose={onPopupClose}
onClick={onPopupButton}>
{ inputs }
</Popup>
}
return <>
<h4>
<Icon icon={"sync"} className={"mx-3 clickable small"} onClick={fetchFiles}/>
File Browser
</h4>
<table className={"table data-table file-table"}>
<thead>
<tr>
<th/>
<th>Name</th>
<th>Type</th>
<th>Size</th>
<th/>
</tr>
</thead>
<tbody>
{rows.length > 0 ? rows :
<tr>
<td colSpan={4} className={"text-center text-black-50"}>
No files uploaded yet
</td>
</tr>
}
</tbody>
</table>
<div className={"file-control-buttons"}>
<button type={"button"} className={"btn btn-success"} disabled={selectedCount === 0} style={!writePermissions ? singleButton : {}}
onClick={() => onDownload(selectedIds)}>
<Icon icon={"download"} className={"mr-1"}/>
Download Selected Files ({selectedCount})
</button>
{
writePermissions ?
<>
<button type={"button"} className={"btn btn-danger"} disabled={selectedCount === 0}
onClick={() => deleteFiles(selectedIds)}>
<Icon icon={"trash"} className={"mr-1"}/>
Delete Selected Files ({selectedCount})
</button>
{api.loggedIn ?
<>
<button type={"button"} className={"btn btn-info"}
onClick={(e) => onPopupOpen("createDirectory")}>
<Icon icon={"plus"} className={"mr-1"}/>
Create Directory
</button>
<button type={"button"} className={"btn btn-primary"} disabled={selectedCount === 0}
onClick={(e) => onPopupOpen("moveFiles")}>
<Icon icon={"plus"} className={"mr-1"}/>
Move Selected Files ({selectedCount})
</button>
</>:
<></>
}
</>
: <></>
}
</div>
{uploadZone}
<div className={"file-browser-restrictions px-4 mb-4"}>
<b>Restrictions:</b>
<span>Max. Files: {restrictions.maxFiles}</span>
<span>Max. Filesize: {formatSize(restrictions.maxSize)}</span>
<span>{restrictions.extensions ? "Allowed extensions: " + restrictions.extensions : "All extensions allowed"}</span>
</div>
<div>
{alertElements}
</div>
{ createPopup() }
</>;
function onPopupOpen(type, target = null) {
setPopup({...popup, visible: true, type: type, target: target});
}
function onPopupClose() {
setPopup({...popup, visible: false});
}
function onPopupChange(e, key) {
setPopup({...popup, [key]: e.target.value});
}
function onPopupButton(btn) {
if (btn === "Ok") {
let parentId = popup.directory === 0 ? null : popup.directory;
if (popup.type === "createDirectory") {
api.createDirectory(popup.name, parentId).then((res) => {
if (!res.success) {
pushAlert(res, "Error creating directory");
} else {
fetchFiles();
}
});
} else if (popup.type === "moveFiles") {
api.moveFiles(selectedIds, parentId).then((res) => {
if (!res.success) {
pushAlert(res, "Error moving files");
} else {
fetchFiles();
}
});
} else if (popup.type === "rename") {
api.rename(popup.target, popup.name, tokenObj.valid ? tokenObj.value : null).then((res) => {
if (!res.success) {
pushAlert(res, "Error renaming file or directory");
} else {
fetchFiles();
}
});
}
}
onPopupClose();
}
function removeUploadedFiles() {
let newFiles = filesToUpload.filter(file => !file.progress || file.progress < 1.0);
if (newFiles.length !== filesToUpload.length) {
setFilesToUpload(newFiles);
}
}
function fetchFiles() {
let promise;
if (tokenObj.valid) {
promise = api.validateToken(tokenObj.value);
} else if (api.loggedIn) {
promise = api.listFiles()
} else {
return; // should never happen
}
promise.then((res) => {
if (res) {
onFetchFiles(res.files);
removeUploadedFiles();
} else {
pushAlert(res);
}
});
}
function pushAlert(res, title) {
let newAlerts = alerts.slice();
newAlerts.push({type: "danger", message: res.msg, title: title});
setAlerts(newAlerts);
}
function removeAlert(i) {
if (i >= 0 && i < alerts.length) {
let newAlerts = alerts.slice();
newAlerts.splice(i, 1);
setAlerts(newAlerts);
}
}
function deleteFiles(selectedIds) {
if (selectedIds && selectedIds.length > 0) {
let token = (api.loggedIn ? null : tokenObj.value);
api.delete(selectedIds, token).then((res) => {
if (res.success) {
fetchFiles();
} else {
pushAlert(res);
}
});
}
}
function onUploadProgress(event, fileIndex) {
if (fileIndex < filesToUpload.length) {
let files = filesToUpload.slice();
files[fileIndex].progress = event.loaded >= event.total ? 1 : event.loaded / event.total;
setFilesToUpload(files);
}
}
function onUpload(fileIndex) {
let token = (api.loggedIn ? null : tokenObj.value);
let parentId = ((!api.loggedIn || popup.directory === 0) ? null : popup.directory);
const file = filesToUpload[fileIndex];
const cancelToken = axios.CancelToken.source();
let newFiles = filesToUpload.slice();
newFiles[fileIndex].cancelToken = cancelToken;
newFiles[fileIndex].progress = 0;
setFilesToUpload(newFiles);
api.upload(file, token, parentId, cancelToken, (e) => onUploadProgress(e, fileIndex)).then((res) => {
let newFiles = filesToUpload.slice();
newFiles[fileIndex].success = res.success;
setFilesToUpload(newFiles);
if (res.success) {
fetchFiles();
} else {
pushAlert(res);
}
}).catch((reason) => {
if (reason && reason.message !== "Upload cancelled") {
pushAlert({ msg: reason }, "Error uploading files");
}
});
}
function onDownload(selectedIds) {
if (selectedIds && selectedIds.length > 0) {
let token = (api.loggedIn ? "" : "&token=" + tokenObj.value);
let ids = selectedIds.map(id => "id[]=" + id).join("&");
let downloadUrl = "/api/file/download?" + ids + token;
fetch(downloadUrl)
.then(response => {
let header = response.headers.get("Content-Disposition") || "";
let fileNameFields = header.split(";").filter(c => c.trim().toLowerCase().startsWith("filename="));
let fileName = null;
if (fileNameFields.length > 0) {
fileName = fileNameFields[0].trim().substr("filename=".length);
} else {
fileName = null;
}
response.blob().then(blob => {
let url = window.URL.createObjectURL(blob);
let a = document.createElement('a');
a.href = url;
if (fileName !== null) a.download = fileName;
a.click();
});
});
}
}
}

@ -1,24 +0,0 @@
import * as React from "react";
export default function Icon(props) {
let classes = props.className || [];
classes = Array.isArray(classes) ? classes : classes.toString().split(" ");
let type = props.type || "fas";
let icon = props.icon;
classes.push(type);
classes.push("fa-" + icon);
if (icon === "spinner" || icon === "circle-notch") {
classes.push("fa-spin");
}
let newProps = {...props, className: classes.join(" ") };
delete newProps["type"];
delete newProps["icon"];
return (
<i {...newProps} />
);
}

@ -1,46 +0,0 @@
import React from 'react';
export function Popup(props) {
let buttonNames = props.buttons || ["Ok", "Cancel"];
let onClick = props.onClick || function () { };
let visible = !!props.visible;
let title = props.title || "Popup Title";
let onClose = props.onClose || function() { };
let buttons = [];
const colors = ["primary", "secondary", "success", "warning", "danger"];
for (let i = 0; i < buttonNames.length; i++) {
let name = buttonNames[i];
let color = colors[i % colors.length];
buttons.push(
<button key={"btn-" + i} type={"button"} className={"btn btn-" + color} data-dismiss={"modal"}
onClick={() => onClick(name)}>
{name}
</button>
);
}
return <>
<div className={"modal fade" + (visible ? " show" : "")} tabIndex="-1" role="dialog" style={{display: (visible) ? "block" : "none"}}>
<div className="modal-dialog" role="document">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">{title}</h5>
<button type="button" className="close" aria-label="Close" onClick={onClose}>
<span aria-hidden="true">&times;</span>
</button>
</div>
<div className="modal-body">
{props.children}
</div>
<div className="modal-footer">
{buttons}
</div>
</div>
</div>
</div>
{visible ? <div className={"modal-backdrop fade show"}/> : <></>}
</>;
}

@ -1,286 +0,0 @@
import * as React from "react";
import Icon from "./icon";
import moment from "moment";
import {Popup} from "./popup";
import Alert from "./alert";
import {useEffect, useState} from "react";
import ReactTooltip from 'react-tooltip';
export function TokenList(props) {
let api = props.api;
let selectedFiles = props.selectedFiles || [];
let directories = props.directories || {};
let [tokens, setTokens] = useState(null);
let [alerts, setAlerts] = useState([]);
let [hideRevoked, setHideRevoked] = useState(true);
let [popup, setPopup] = useState({
tokenType: "download",
maxFiles: 0,
maxSize: 0,
extensions: "",
durability: 24 * 60 * 2,
visible: false,
directory: 0
});
useEffect(() => {
setTimeout(() => {
if (tokens) {
let hasChanged = false;
let newTokens = tokens.slice();
for (let token of newTokens) {
if (token.tooltip) hasChanged = true;
token.tooltip = false;
}
if (hasChanged) setTokens(newTokens);
}
}, 1500);
}, [tokens]);
function fetchTokens() {
api.listTokens().then((res) => {
if (res.success) {
setTokens(res.tokens);
} else {
pushAlert(res, "Error fetching tokens");
setTokens([]);
}
});
}
let rows = [];
if (tokens === null) {
fetchTokens();
} else {
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
const validUntil = token.valid_until;
const revoked = validUntil !== null && moment(validUntil).isSameOrBefore(new Date());
if (revoked && hideRevoked) {
continue;
}
const timeStr = (validUntil === null ? "Forever" : moment(validUntil).format("Do MMM YYYY, HH:mm"));
const tooltipPropsRevoke = (revoked ? {} : {
"data-tip": "Revoke", "data-place": "bottom", "data-type": "error",
"data-effect": "solid", "data-for": "tooltip-token-" + token.uid,
});
const tooltipPropsCopy = (revoked ? {} : {
"data-tip": "", "data-place": "bottom", "data-type": "info",
"data-effect": "solid", "data-for": "tooltip-token-" + token.uid,
});
rows.push(
<tr key={"token-" + token.uid} className={revoked ? "token-revoked" : ""}>
<td>{token.token}</td>
<td>{token.type}</td>
<td>{timeStr}</td>
<td>
{ revoked ? <></> : <ReactTooltip id={"tooltip-token-" + token.uid}
getContent={(x) => { if (x === "Revoke") return x; return (!!token.tooltip ? "Coped successfully!" : "Copy to Clipboard"); }} /> }
<Icon icon={"times"} className={"clickable text-" + (revoked ? "secondary" : "danger")}
onClick={() => (revoked ? null : onRevokeToken(token.token))} disabled={revoked}
{...tooltipPropsRevoke} />
<Icon icon={"save"} className={"clickable text-" + (revoked ? "secondary" : "info")}
onClick={() => (revoked ? null : onCopyToken(i))} disabled={revoked}
{...tooltipPropsCopy} />
</td>
</tr>
);
}
}
let alertElements = [];
for (let i = 0; i < alerts.length; i++) {
const alert = alerts[i];
alertElements.push(
<Alert key={"alert-" + i} {...alert} onClose={() => removeAlert(i)}/>
);
}
let options = [];
for (const [uid, dir] of Object.entries(directories)) {
options.push(
<option key={"option-" + dir} value={uid}>{dir}</option>
);
}
return <>
<h4>
<Icon icon={"sync"} className={"mx-3 clickable small"} onClick={fetchTokens}/>
Tokens
</h4>
<div className={"form-check p-3 ml-3"}>
<input type={"checkbox"} checked={hideRevoked} name={"hide-revoked"}
className={"form-check-input"} style={{marginTop: "0.2rem"}}
onChange={(e) => setHideRevoked(e.target.checked)}/>
<label htmlFor={"hide-revoked"} className={"form-check-label pl-2"}>Hide revoked</label>
</div>
<table className={"table token-table"}>
<thead>
<tr>
<th>Token</th>
<th>Type</th>
<th>Valid Until</th>
<th/>
</tr>
</thead>
<tbody>
{rows.length > 0 ? rows :
<tr>
<td colSpan={4} className={"text-center text-black-50"}>
No active tokens connected with this account
</td>
</tr>
}
</tbody>
</table>
<div>
<button type={"button"} className={"btn btn-success m-2"} onClick={onPopupOpen}>
<Icon icon={"plus"} className={"mr-1"}/>
Create Token
</button>
</div>
<div>
{alertElements}
</div>
<Popup title={"Create Token"} visible={popup.visible} buttons={["Ok", "Cancel"]}
onClose={onPopupClose} onClick={onPopupButton}>
<div className={"form-group"}>
<label>Token Durability in minutes (0 = forever):</label>
<input type={"number"} min={0} className={"form-control"}
value={popup.durability} onChange={(e) => onPopupChange(e, "durability")}/>
</div>
<div className="form-group">
<label>Token Type:</label>
<select value={popup.tokenType} className={"form-control"}
onChange={(e) => onPopupChange(e, "tokenType")}>
<option value={"upload"}>Upload</option>
<option value={"download"}>Download</option>
</select>
</div>
{popup.tokenType === "upload" ?
<>
<div className={"form-group"}>
<label>Destination Directory:</label>
<select value={popup.directory} className={"form-control"}
onChange={(e) => onPopupChange(e, "directory")}>
{ options }
</select>
</div>
<b>Upload Restrictions:</b>
<div className={"form-group"}>
<label>Max. Files (0 = unlimited):</label>
<input type={"number"} min={0} max={25} className={"form-control"}
value={popup.maxFiles}
onChange={(e) => onPopupChange(e, "maxFiles")}/>
</div>
<div className={"form-group"}>
<label>Max. Size per file in MiB (0 = unlimited):</label>
<input type={"number"} min={0} max={10} className={"form-control"}
value={popup.maxSize} onChange={(e) => onPopupChange(e, "maxSize")}/>
</div>
<div className={"form-group"}>
<label>Allowed Extensions:</label>
<input type={"text"} placeholder={"(no restrictions)"} maxLength={256}
className={"form-control"}
value={popup.extensions}
onChange={(e) => onPopupChange(e, "extensions")}/>
</div>
</> :
<></>
}
</Popup>
</>;
function pushAlert(res, title) {
let newAlerts = alerts.slice();
newAlerts.push({type: "danger", message: res.msg, title: title});
setAlerts(newAlerts);
}
function removeAlert(i) {
if (i >= 0 && i < alerts.length) {
let newAlerts = alerts.slice();
newAlerts.splice(i, 1);
setAlerts(newAlerts);
}
}
function onRevokeToken(token) {
api.revokeToken(token).then((res) => {
if (res.success) {
let newTokens = tokens.slice();
for (const tokenObj of newTokens) {
if (tokenObj.token === token) {
tokenObj.valid_until = moment();
break;
}
}
setTokens(newTokens);
} else {
pushAlert(res, "Error revoking token");
}
});
}
function onPopupOpen() {
setPopup({...popup, visible: true});
}
function onPopupClose() {
setPopup({...popup, visible: false});
}
function onPopupChange(e, key) {
setPopup({...popup, [key]: e.target.value});
}
function onPopupButton(btn) {
if (btn === "Ok") {
let durability = popup.durability;
let validUntil = (durability === 0 ? null : moment().add(durability, "hours").format("YYYY-MM-DD HH:mm:ss"));
if (popup.tokenType === "download") {
api.createDownloadToken(durability, selectedFiles).then((res) => {
if (!res.success) {
pushAlert(res, "Error creating token");
} else {
let newTokens = tokens.slice();
newTokens.push({token: res.token, valid_until: validUntil, type: "download"});
setTokens(newTokens);
}
});
} else if (popup.tokenType === "upload") {
let parentId = popup.directory === 0 ? null : popup.directory;
let maxSize = Math.round(popup.maxSize * 1024 * 1024);
api.createUploadToken(durability, parentId, popup.maxFiles, maxSize, popup.extensions).then((res) => {
if (!res.success) {
pushAlert(res, "Error creating token");
} else {
let newTokens = tokens.slice();
newTokens.push({uid: res.tokenId, token: res.token, valid_until: validUntil, type: "upload"});
setTokens(newTokens);
}
});
}
}
onPopupClose();
}
function onCopyToken(index) {
let newTokens = tokens.slice();
let token = newTokens[index].token;
let url = window.location.href;
if (!url.endsWith("/")) url += "/";
url += token;
navigator.clipboard.writeText(url);
newTokens[index].tooltip = true;
setTokens(newTokens);
}
}

@ -1,230 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom';
import API from "./api";
import Icon from "./elements/icon";
import {FileBrowser} from "./elements/file-browser";
import {TokenList} from "./elements/token-list";
class FileControlPanel extends React.Component {
constructor(props) {
super(props);
this.api = new API();
this.state = {
loaded: false,
validatingToken: false,
errorMessage: "",
user: { },
token: { valid: false, value: "", validUntil: null, type: null },
files: {},
restrictions: { maxFiles: 0, maxSize: 0, extensions: "" }
};
}
onFetchFiles(files) {
this.setState({ ...this.state, files: files });
}
getDirectories(prefix = "/", items = null) {
let directories = { }
items = items || this.state.files;
if (prefix === "/") {
directories[0] = "/";
}
for(const fileItem of Object.values(items)) {
if (fileItem.isDirectory) {
let path = prefix + (prefix.length > 1 ? "/" : "") + fileItem.name;
directories[fileItem.uid] = path;
directories = Object.assign(directories, {...this.getDirectories(path, fileItem.items)});
}
}
return directories;
}
getSelectedIds(items = null, recursive = true) {
let ids = [];
items = items || this.state.files;
for (const fileItem of Object.values(items)) {
if (fileItem.selected) {
ids.push(fileItem.uid);
}
if (recursive && fileItem.isDirectory) {
ids.push(...this.getSelectedIds(fileItem.items));
}
}
return ids;
}
onSelectAll(selected, items) {
for (const fileElement of Object.values(items)) {
fileElement.selected = selected;
if (fileElement.isDirectory) {
this.onSelectAll(selected, fileElement.items);
}
}
}
onSelectFile(e, uid, items=null) {
let found = false;
let updatedFiles = (items === null) ? {...this.state.files} : items;
if (updatedFiles.hasOwnProperty(uid)) {
let fileElement = updatedFiles[uid];
found = true;
fileElement.selected = e.target.checked;
if (fileElement.isDirectory) {
this.onSelectAll(fileElement.selected, fileElement.items);
}
} else {
for (const fileElement of Object.values(updatedFiles)) {
if (fileElement.isDirectory) {
if (this.onSelectFile(e, uid, fileElement.items)) {
if (!e.target.checked) {
fileElement.selected = false;
}/* else if (this.getSelectedIds(fileElement.items, false).length === Object.values(fileElement.items).length) {
fileElement.selected = true;
}*/
found = true;
break;
}
}
}
}
if (items === null) {
this.setState({
...this.state,
files: updatedFiles
});
}
return found;
}
onValidateToken(token = null) {
if (token === null) {
this.setState({ ...this.state, validatingToken: true, errorMessage: "" });
token = this.state.token.value;
}
this.api.validateToken(token).then((res) => {
let newState = { ...this.state, loaded: true, validatingToken: false };
if (res.success) {
newState.token = { ...this.state.token, valid: true, validUntil: res.token.valid_until, type: res.token.type };
if (!newState.token.value) {
newState.token.value = token;
}
newState.files = res.files;
newState.restrictions = res.restrictions;
} else {
newState.token.value = (newState.token.value ? "" : token);
newState.errorMessage = res.msg;
}
this.setState(newState);
});
}
onUpdateToken(e) {
this.setState({ ...this.state, token: { ...this.state.token, value: e.target.value } });
}
render() {
const self = this;
const errorMessageShown = !!this.state.errorMessage;
// still loading
if (!this.state.loaded) {
let checkUser = true;
let pathName = window.location.pathname;
if (pathName.startsWith("/files")) {
pathName = pathName.substr("/files".length);
}
if (pathName.length > 1) {
let end = (pathName.endsWith("/") ? pathName.length - 2 : pathName.length - 1);
let start = (pathName.startsWith("/files/") ? ("/files/").length : 1);
let token = pathName.substr(start, end);
if (token) {
this.onValidateToken(token);
checkUser = false;
}
}
if (checkUser) {
this.api.fetchUser().then((isLoggedIn) => {
if (isLoggedIn) {
this.api.listFiles().then((res) => {
this.setState({ ...this.state, loaded: true, user: this.api.user, files: res.files });
this.api.getRestrictions().then((res) => {
this.setState({ ...this.state, restrictions: res.restrictions });
})
});
} else {
this.setState({ ...this.state, loaded: true, user: this.api.user });
}
});
}
return <>Loading <Icon icon={"spinner"} /></>;
}
// access granted
else if (this.api.loggedIn || this.state.token.valid) {
let selectedIds = this.getSelectedIds();
let directories = this.getDirectories();
let tokenList = (this.api.loggedIn ?
<div className={"row"}>
<div className={"col-lg-8 col-md-10 col-sm-12 mx-auto"}>
<TokenList api={this.api} selectedFiles={selectedIds} directories={directories} />
</div>
</div> : <></>
);
return <>
<div className={"container mt-4"}>
<div className={"row"}>
<div className={"col-lg-8 col-md-10 col-sm-12 mx-auto"}>
<h2>File Control Panel</h2>
<FileBrowser files={this.state.files} token={this.state.token} api={this.api}
restrictions={this.state.restrictions} directories={directories}
onSelectFile={this.onSelectFile.bind(this)}
onFetchFiles={this.onFetchFiles.bind(this)}/>
</div>
</div>
{ tokenList }
</div>
</>;
} else {
return <div className={"container mt-4"}>
<div className={"row"}>
<div className={"col-lg-8 col-md-10 col-sm-12 mx-auto"}>
<h2>File Control Panel</h2>
<form onSubmit={(e) => e.preventDefault()}>
<label htmlFor={"token"}>Enter a file token to download or upload files</label>
<input type={"text"} className={"form-control"} name={"token"} placeholder={"Enter token…"} maxLength={36}
value={this.state.token.value} onChange={(e) => self.onUpdateToken(e)}/>
<button className={"btn btn-success mt-2"} onClick={() => this.onValidateToken()} disabled={this.state.validatingToken}>
{ this.state.validatingToken ? <>Validating <Icon icon={"spinner"}/></> : "Submit" }
</button>
</form>
<div className={"alert alert-danger mt-2"} hidden={!errorMessageShown}>
{ this.state.errorMessage }
</div>
<div className={"mt-3"}>
Or either <a href={"/admin"}>login</a> to access the file control panel.
</div>
</div>
</div>
</div>;
}
}
}
ReactDOM.render(
<FileControlPanel />,
document.getElementById('root')
);

@ -1,17 +0,0 @@
module.exports = {
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
use: {
loader: "babel-loader"
}
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader']
}
]
}
};

@ -28,15 +28,16 @@ $sql = $user->getSQL();
$settings = $config->getSettings();
$installation = !$sql || ($sql->isConnected() && !$settings->isInstalled());
if(isset($_GET["api"]) && is_string($_GET["api"])) {
header("Content-Type: application/json");
if($installation) {
if (isset($_GET["api"]) && is_string($_GET["api"])) {
$isApiResponse = true;
if ($installation) {
$response = createError("Not installed");
} else {
$apiFunction = $_GET["api"];
if(empty($apiFunction)) {
http_response_code(403);
$response = "";
if (empty($apiFunction) || $apiFunction === "/") {
$document = new \Elements\TemplateDocument($user, "swagger.twig");
$response = $document->getCode();
$isApiResponse = false;
} else if(!preg_match("/[a-zA-Z]+(\/[a-zA-Z]+)*/", $apiFunction)) {
http_response_code(400);
$response = createError("Invalid Method");
@ -72,6 +73,12 @@ if(isset($_GET["api"]) && is_string($_GET["api"])) {
$response = createError("Error instantiating class: $e");
}
}
if ($isApiResponse) {
header("Content-Type: application/json");
} else {
header("Content-Type: text/html");
}
}
} else {
$requestedUri = $_GET["site"] ?? $_SERVER["REQUEST_URI"];

3
js/swagger-ui-bundle.js Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -140,4 +140,33 @@ class AesStreamTest extends PHPUnit\Framework\TestCase {
$inputData = random_bytes($inputSize);
$this->testEncryptDecrypt($key, $iv, $inputData);
}
public function testEncryptDecryptPartial() {
$key = random_bytes(32);
$iv = hex2bin(str_repeat("00", 16));
$chunkSize = 65536;
$ranges = [[500,100,200],[10*$chunkSize,100,5*$chunkSize+100],[10*$chunkSize,0,10*$chunkSize],[10*$chunkSize,$chunkSize-1,3*$chunkSize-1]];
foreach ($ranges as $range) {
list ($total, $offset, $length) = $range;
$inputData = random_bytes($total);
file_put_contents(AesStreamTest::$TEMP_FILE, $inputData);
$output = "";
$aesStream = new AesStream($key, $iv);
$aesStream->setRange($offset, $length);
$aesStream->setInputFile(AesStreamTest::$TEMP_FILE);
$aesStream->setOutput(function($chunk) use (&$output) { $this->getOutput($chunk, $output); });
$aesStream->start();
$outputComplete = "";
$aesStream = new AesStream($key, $iv);
$aesStream->setInputFile(AesStreamTest::$TEMP_FILE);
$aesStream->setOutput(function($chunk) use (&$outputComplete) { $this->getOutput($chunk, $outputComplete); });
$aesStream->start();
$this->assertEquals($length, strlen($output), "total=$total offset=$offset length=$length");
$this->assertEquals(substr($outputComplete, $offset, $length), $output, "total=$total offset=$offset length=$length");
}
}
}

@ -0,0 +1,43 @@
<?php
use Base32\Base32;
use Configuration\Configuration;
use Objects\TwoFactor\TimeBasedTwoFactorToken;
use Objects\User;
class TimeBasedTwoFactorTokenTest extends PHPUnit\Framework\TestCase {
// https://tools.ietf.org/html/rfc6238
public function testTOTP() {
$secret = Base32::encode("12345678901234567890");
$token = new TimeBasedTwoFactorToken($secret);
$totp_tests = [
59 => '94287082',
1111111109 => '07081804',
1111111111 => '14050471',
1234567890 => '89005924',
2000000000 => '69279037',
20000000000 => '65353130',
];
$period = 30;
$totp_length = 8;
foreach ($totp_tests as $seed => $code) {
$generated = $token->generate($seed, $totp_length, $period);
$this->assertEquals($code, $generated, "$code != $generated, at=$seed");
}
}
public function testURL() {
$secret = Base32::encode("12345678901234567890");
$configuration = new Configuration();
$user = new User($configuration);
$token = new TimeBasedTwoFactorToken($secret);
$siteName = $configuration->getSettings()->getSiteName();
$username = $user->getUsername();
$url = $token->getUrl($user);
$this->assertEquals("otpauth://totp/$username?secret=$secret&issuer=$siteName", $url);
}
}