Core Update 1.4.0
This commit is contained in:
parent
918244125c
commit
bd1f302433
@ -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
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
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
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
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(),
|
||||
|
7
core/External/composer.json
vendored
7
core/External/composer.json
vendored
@ -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()); }
|
||||
|
||||
}
|
||||
|
136
core/Objects/GpgKey.class.php
Normal file
136
core/Objects/GpgKey.class.php
Normal file
@ -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;
|
||||
}
|
||||
|
||||
}
|
13
core/Objects/KeyBasedTwoFactorToken.class.php
Normal file
13
core/Objects/KeyBasedTwoFactorToken.class.php
Normal file
@ -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)
|
||||
|
52
core/Objects/TimeBasedTwoFactorToken.class.php
Normal file
52
core/Objects/TimeBasedTwoFactorToken.class.php
Normal file
@ -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;
|
||||
}
|
||||
}
|
37
core/Objects/TwoFactor/AttestationObject.class.php
Normal file
37
core/Objects/TwoFactor/AttestationObject.class.php
Normal file
@ -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;
|
||||
}
|
||||
|
||||
}
|
79
core/Objects/TwoFactor/AuthenticationData.class.php
Normal file
79
core/Objects/TwoFactor/AuthenticationData.class.php
Normal file
@ -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;
|
||||
}
|
||||
}
|
16
core/Objects/TwoFactor/CBORDecoder.trait.php
Normal file
16
core/Objects/TwoFactor/CBORDecoder.trait.php
Normal file
@ -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));
|
||||
}
|
||||
|
||||
}
|
74
core/Objects/TwoFactor/KeyBasedTwoFactorToken.class.php
Normal file
74
core/Objects/TwoFactor/KeyBasedTwoFactorToken.class.php
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
67
core/Objects/TwoFactor/PublicKey.class.php
Normal file
67
core/Objects/TwoFactor/PublicKey.class.php
Normal file
@ -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);
|
||||
}
|
||||
}
|
59
core/Objects/TwoFactor/TimeBasedTwoFactorToken.class.php
Normal file
59
core/Objects/TwoFactor/TimeBasedTwoFactorToken.class.php
Normal file
@ -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;
|
||||
}
|
||||
}
|
62
core/Objects/TwoFactor/TwoFactorToken.class.php
Normal file
62
core/Objects/TwoFactor/TwoFactorToken.class.php
Normal file
@ -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;
|
||||
}
|
||||
}
|
63
core/Objects/TwoFactorToken.class.php
Normal file
63
core/Objects/TwoFactorToken.class.php
Normal file
@ -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']));
|
||||
}
|
||||
|
16
core/Templates/account/account.twig
Normal file
16
core/Templates/account/account.twig
Normal file
@ -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
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>
|
6
core/Templates/mail/2fa_remove.twig
Normal file
6
core/Templates/mail/2fa_remove.twig
Normal file
@ -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
|
8
core/Templates/mail/register.twig
Normal file
8
core/Templates/mail/register.twig
Normal file
@ -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
|
54
core/Templates/swagger.twig
Normal file
54
core/Templates/swagger.twig
Normal file
@ -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>
|
89
core/Views/Account/AcceptInvite.class.php
Normal file
89
core/Views/Account/AcceptInvite.class.php
Normal file
@ -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>";
|
||||
}
|
||||
}
|
61
core/Views/Account/AccountView.class.php
Normal file
61
core/Views/Account/AccountView.class.php
Normal file
@ -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();
|
||||
}
|
55
core/Views/Account/ConfirmEmail.class.php
Normal file
55
core/Views/Account/ConfirmEmail.class.php
Normal file
@ -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;
|
||||
}
|
||||
}
|
70
core/Views/Account/Register.class.php
Normal file
70
core/Views/Account/Register.class.php
Normal file
@ -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>";
|
||||
}
|
||||
}
|
39
core/Views/Account/ResendConfirmEmail.class.php
Normal file
39
core/Views/Account/ResendConfirmEmail.class.php
Normal file
@ -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>
|
||||
";
|
||||
}
|
||||
}
|
99
core/Views/Account/ResetPassword.class.php
Normal file
99
core/Views/Account/ResetPassword.class.php
Normal file
@ -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>";
|
||||
}
|
||||
}
|
||||
}
|
20
core/Views/Admin/AdminDashboardBody.class.php
Normal file
20
core/Views/Admin/AdminDashboardBody.class.php
Normal file
@ -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;
|
||||
}
|
||||
}
|
72
core/Views/Admin/LoginBody.class.php
Normal file
72
core/Views/Admin/LoginBody.class.php
Normal file
@ -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 $backToStartPage</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>";
|
||||
|
||||
return $html;
|
||||
}
|
||||
}
|
13
core/Views/View404.class.php
Normal file
13
core/Views/View404.class.php
Normal file
@ -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
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"]
|
||||
}
|
24
fileControlPanel/.gitignore
vendored
24
fileControlPanel/.gitignore
vendored
@ -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
|
5
fileControlPanel/.idea/.gitignore
vendored
5
fileControlPanel/.idea/.gitignore
vendored
@ -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>
|
38159
fileControlPanel/package-lock.json
generated
38159
fileControlPanel/package-lock.json
generated
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">×</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']
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
19
index.php
19
index.php
@ -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
3
js/swagger-ui-bundle.js
Normal file
File diff suppressed because one or more lines are too long
3
js/swagger-ui-standalone-preset.js
Normal file
3
js/swagger-ui-standalone-preset.js
Normal file
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");
|
||||
}
|
||||
}
|
||||
}
|
43
test/TimeBasedTwoFactorToken.test.php
Normal file
43
test/TimeBasedTwoFactorToken.test.php
Normal file
@ -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);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user