frontend & backend update

This commit is contained in:
Roman 2023-01-16 21:47:23 +01:00
parent 1d6ff17994
commit 4cec531a25
51 changed files with 1010 additions and 571 deletions

@ -18,6 +18,7 @@ namespace Core\API\ApiKey {
use Core\API\Parameter\Parameter; use Core\API\Parameter\Parameter;
use Core\Driver\SQL\Condition\Compare; use Core\Driver\SQL\Condition\Compare;
use Core\Driver\SQL\Condition\CondAnd; use Core\Driver\SQL\Condition\CondAnd;
use Core\Driver\SQL\Query\Insert;
use Core\Objects\Context; use Core\Objects\Context;
use Core\Objects\DatabaseEntity\ApiKey; use Core\Objects\DatabaseEntity\ApiKey;
@ -46,6 +47,10 @@ namespace Core\API\ApiKey {
return $this->success; return $this->success;
} }
public static function getDefaultACL(Insert $insert): void {
$insert->addRow(self::getEndpoint(), [], "Allows users to create new API-Keys");
}
} }
class Fetch extends ApiKeyAPI { class Fetch extends ApiKeyAPI {
@ -82,6 +87,10 @@ namespace Core\API\ApiKey {
return $this->success; return $this->success;
} }
public static function getDefaultACL(Insert $insert): void {
$insert->addRow(self::getEndpoint(), [], "Allows users to fetch new API-Key");
}
} }
class Refresh extends ApiKeyAPI { class Refresh extends ApiKeyAPI {
@ -112,6 +121,10 @@ namespace Core\API\ApiKey {
return $this->success; return $this->success;
} }
public static function getDefaultACL(Insert $insert): void {
$insert->addRow(self::getEndpoint(), [], "Allows users to refresh API-Key");
}
} }
class Revoke extends ApiKeyAPI { class Revoke extends ApiKeyAPI {
@ -138,5 +151,9 @@ namespace Core\API\ApiKey {
return $this->success; return $this->success;
} }
public static function getDefaultACL(Insert $insert): void {
$insert->addRow(self::getEndpoint(), [], "Allows users to revoke API-Key");
}
} }
} }

@ -12,8 +12,10 @@ namespace Core\API\Database {
use Core\API\DatabaseAPI; use Core\API\DatabaseAPI;
use Core\API\Parameter\StringType; use Core\API\Parameter\StringType;
use Core\Driver\SQL\Query\Insert;
use Core\Objects\Context; use Core\Objects\Context;
use Core\Objects\DatabaseEntity\Controller\DatabaseEntity; use Core\Objects\DatabaseEntity\Controller\DatabaseEntity;
use Core\Objects\DatabaseEntity\Group;
class Status extends DatabaseAPI { class Status extends DatabaseAPI {
@ -29,6 +31,10 @@ namespace Core\API\Database {
return true; return true;
} }
public static function getDefaultACL(Insert $insert): void {
$insert->addRow(self::getEndpoint(), [Group::ADMIN], "Allows users to view the database status");
}
} }
class Migrate extends DatabaseAPI { class Migrate extends DatabaseAPI {
@ -98,5 +104,9 @@ namespace Core\API\Database {
return true; return true;
} }
public static function getDefaultACL(Insert $insert): void {
$insert->addRow(self::getEndpoint(), [Group::ADMIN], "Allows users to migrate the database structure");
}
} }
} }

@ -36,8 +36,8 @@ namespace Core\API\Groups {
use Core\Driver\SQL\Expression\Alias; use Core\Driver\SQL\Expression\Alias;
use Core\Driver\SQL\Expression\Count; use Core\Driver\SQL\Expression\Count;
use Core\Driver\SQL\Join\InnerJoin; use Core\Driver\SQL\Join\InnerJoin;
use Core\Driver\SQL\Query\Insert;
use Core\Objects\Context; use Core\Objects\Context;
use Core\Objects\DatabaseEntity\Controller\NMRelation;
use Core\Objects\DatabaseEntity\Group; use Core\Objects\DatabaseEntity\Group;
use Core\Objects\DatabaseEntity\User; use Core\Objects\DatabaseEntity\User;
@ -83,6 +83,10 @@ namespace Core\API\Groups {
return $this->success; return $this->success;
} }
public static function getDefaultACL(Insert $insert): void {
$insert->addRow(self::getEndpoint(), [Group::ADMIN, Group::SUPPORT], "Allows users to fetch available groups");
}
} }
class Get extends GroupsAPI { class Get extends GroupsAPI {
@ -106,6 +110,10 @@ namespace Core\API\Groups {
return true; return true;
} }
public static function getDefaultACL(Insert $insert): void {
$insert->addRow(self::getEndpoint(), [Group::ADMIN, Group::SUPPORT], "Allows users to get details about a group");
}
} }
class GetMembers extends GroupsAPI { class GetMembers extends GroupsAPI {
@ -142,6 +150,9 @@ namespace Core\API\Groups {
return true; return true;
} }
public static function getDefaultACL(Insert $insert): void {
$insert->addRow(self::getEndpoint(), [Group::ADMIN, Group::SUPPORT], "Allows users to fetch members of a group");
}
} }
class Create extends GroupsAPI { class Create extends GroupsAPI {
@ -182,6 +193,10 @@ namespace Core\API\Groups {
return $this->success; return $this->success;
} }
public static function getDefaultACL(Insert $insert): void {
$insert->addRow(self::getEndpoint(), [Group::ADMIN], "Allows users to create a new group");
}
} }
class Delete extends GroupsAPI { class Delete extends GroupsAPI {
@ -209,6 +224,10 @@ namespace Core\API\Groups {
return $this->success; return $this->success;
} }
} }
public static function getDefaultACL(Insert $insert): void {
$insert->addRow(self::getEndpoint(), [Group::ADMIN], "Allows users to delete a group");
}
} }
class AddMember extends GroupsAPI { class AddMember extends GroupsAPI {
@ -247,6 +266,10 @@ namespace Core\API\Groups {
return true; return true;
} }
} }
public static function getDefaultACL(Insert $insert): void {
$insert->addRow(self::getEndpoint(), [Group::ADMIN], "Allows users to add members to a group");
}
} }
class RemoveMember extends GroupsAPI { class RemoveMember extends GroupsAPI {
@ -285,6 +308,9 @@ namespace Core\API\Groups {
return true; return true;
} }
} }
}
public static function getDefaultACL(Insert $insert): void {
$insert->addRow(self::getEndpoint(), [Group::ADMIN], "Allows users to remove members from a group");
}
}
} }

@ -21,7 +21,9 @@ namespace Core\API\Logs {
use Core\Driver\SQL\Column\Column; use Core\Driver\SQL\Column\Column;
use Core\Driver\SQL\Condition\Compare; use Core\Driver\SQL\Condition\Compare;
use Core\Driver\SQL\Condition\CondIn; use Core\Driver\SQL\Condition\CondIn;
use Core\Driver\SQL\Query\Insert;
use Core\Objects\Context; use Core\Objects\Context;
use Core\Objects\DatabaseEntity\Group;
use Core\Objects\DatabaseEntity\SystemLog; use Core\Objects\DatabaseEntity\SystemLog;
class Get extends LogsAPI { class Get extends LogsAPI {
@ -118,6 +120,10 @@ namespace Core\API\Logs {
return true; return true;
} }
public static function getDefaultACL(Insert $insert): void {
$insert->addRow(self::getEndpoint(), [Group::ADMIN], "Allows users to fetch system logs");
}
} }
} }

@ -46,6 +46,8 @@ namespace Core\API\Mail {
use Core\API\MailAPI; use Core\API\MailAPI;
use Core\API\Parameter\Parameter; use Core\API\Parameter\Parameter;
use Core\API\Parameter\StringType; use Core\API\Parameter\StringType;
use Core\Driver\SQL\Query\Insert;
use Core\Objects\DatabaseEntity\Group;
use Core\Objects\DatabaseEntity\MailQueueItem; use Core\Objects\DatabaseEntity\MailQueueItem;
use DateTimeInterface; use DateTimeInterface;
use Core\Driver\SQL\Condition\Compare; use Core\Driver\SQL\Condition\Compare;
@ -78,6 +80,10 @@ namespace Core\API\Mail {
$this->lastError = $req->getLastError(); $this->lastError = $req->getLastError();
return $this->success; return $this->success;
} }
public static function getDefaultACL(Insert $insert): void {
$insert->addRow(self::getEndpoint(), [Group::ADMIN], "Allows users to send a test email to verify configuration");
}
} }
class Send extends MailAPI { class Send extends MailAPI {

@ -31,6 +31,7 @@ namespace Core\API\Permission {
use Core\Driver\SQL\Condition\CondIn; use Core\Driver\SQL\Condition\CondIn;
use Core\Driver\SQL\Condition\CondLike; use Core\Driver\SQL\Condition\CondLike;
use Core\Driver\SQL\Condition\CondNot; use Core\Driver\SQL\Condition\CondNot;
use Core\Driver\SQL\Query\Insert;
use Core\Driver\SQL\Strategy\UpdateStrategy; use Core\Driver\SQL\Strategy\UpdateStrategy;
use Core\Objects\Context; use Core\Objects\Context;
use Core\Objects\DatabaseEntity\Group; use Core\Objects\DatabaseEntity\Group;
@ -73,6 +74,16 @@ namespace Core\API\Permission {
http_response_code(401); http_response_code(401);
return $this->createError("Permission denied."); return $this->createError("Permission denied.");
} }
// user would have required groups, check for 2fa-state
if ($currentUser) {
$tfaToken = $currentUser->getTwoFactorToken();
if ($tfaToken && $tfaToken->isConfirmed() && !$tfaToken->isAuthenticated()) {
$this->lastError = '2FA-Authorization is required';
http_response_code(401);
return false;
}
}
} }
return $this->success; return $this->success;
@ -127,6 +138,10 @@ namespace Core\API\Permission {
return $this->success; return $this->success;
} }
public static function getDefaultACL(Insert $insert): void {
$insert->addRow(self::getEndpoint(), [Group::ADMIN], "Allows users to fetch API permissions");
}
} }
class Save extends PermissionAPI { class Save extends PermissionAPI {
@ -192,5 +207,10 @@ namespace Core\API\Permission {
return $this->success; return $this->success;
} }
public static function getDefaultACL(Insert $insert): void {
$insert->addRow(self::getEndpoint(), [Group::ADMIN],
"Allows users to modify API permissions. This is restricted to the administrator and cannot be changed");
}
} }
} }

@ -3,15 +3,10 @@
namespace Core\API; namespace Core\API;
use Core\Driver\Logger\Logger; use Core\Driver\Logger\Logger;
use Core\Driver\SQL\Query\Insert;
use Core\Objects\Context; use Core\Objects\Context;
use PhpMqtt\Client\MqttClient; use PhpMqtt\Client\MqttClient;
/**
* TODO: we need following features, probably as abstract/generic class/method:
* - easy way for pagination (select with limit/offset)
* - CRUD Endpoints/Objects (Create, Update, Delete)
*/
abstract class Request { abstract class Request {
protected Context $context; protected Context $context;
@ -62,7 +57,7 @@ abstract class Request {
} }
} }
protected function forbidMethod($method) { protected function forbidMethod($method): void {
if (($key = array_search($method, $this->allowedMethods)) !== false) { if (($key = array_search($method, $this->allowedMethods)) !== false) {
unset($this->allowedMethods[$key]); unset($this->allowedMethods[$key]);
} }
@ -76,7 +71,7 @@ abstract class Request {
return $this->isDisabled; return $this->isDisabled;
} }
protected function allowMethod($method) { protected function allowMethod($method): void {
$availableMethods = ["GET", "HEAD", "POST", "PUT", "DELETE", "PATCH", "TRACE", "CONNECT"]; $availableMethods = ["GET", "HEAD", "POST", "PUT", "DELETE", "PATCH", "TRACE", "CONNECT"];
if (in_array($method, $availableMethods) && !in_array($method, $this->allowedMethods)) { if (in_array($method, $availableMethods) && !in_array($method, $this->allowedMethods)) {
$this->allowedMethods[] = $method; $this->allowedMethods[] = $method;
@ -113,7 +108,7 @@ abstract class Request {
return true; return true;
} }
public function parseVariableParams($values) { public function parseVariableParams($values): void {
foreach ($values as $name => $value) { foreach ($values as $name => $value) {
if (isset($this->params[$name])) continue; if (isset($this->params[$name])) continue;
$type = Parameter\Parameter::parseType($value); $type = Parameter\Parameter::parseType($value);
@ -129,6 +124,7 @@ abstract class Request {
} }
protected abstract function _execute(): bool; protected abstract function _execute(): bool;
public static function getDefaultACL(Insert $insert): void { }
public final function execute($values = array()): bool { public final function execute($values = array()): bool {
@ -203,9 +199,11 @@ abstract class Request {
} else if ($session) { } else if ($session) {
$tfaToken = $session->getUser()->getTwoFactorToken(); $tfaToken = $session->getUser()->getTwoFactorToken();
if ($tfaToken && $tfaToken->isConfirmed() && !$tfaToken->isAuthenticated()) { if ($tfaToken && $tfaToken->isConfirmed() && !$tfaToken->isAuthenticated()) {
$this->lastError = '2FA-Authorization is required'; if (!($this instanceof \Core\API\Tfa\VerifyTotp) && !($this instanceof \Core\API\Tfa\VerifyKey)) {
http_response_code(401); $this->lastError = '2FA-Authorization is required';
return false; http_response_code(401);
return false;
}
} }
} }
} }
@ -225,7 +223,7 @@ abstract class Request {
// Check for permission // Check for permission
if (!($this instanceof \Core\API\Permission\Save)) { if (!($this instanceof \Core\API\Permission\Save)) {
$req = new \Core\API\Permission\Check($this->context); $req = new \Core\API\Permission\Check($this->context);
$this->success = $req->execute(array("method" => $this->getMethod())); $this->success = $req->execute(array("method" => self::getEndpoint()));
$this->lastError = $req->getLastError(); $this->lastError = $req->getLastError();
if (!$this->success) { if (!$this->success) {
return false; return false;
@ -266,11 +264,11 @@ abstract class Request {
} }
protected function getParam($name, $obj = NULL): mixed { protected function getParam($name, $obj = NULL): mixed {
// I don't know why phpstorm
if ($obj === NULL) { if ($obj === NULL) {
$obj = $this->params; $obj = $this->params;
} }
// I don't know why phpstorm
return (isset($obj[$name]) ? $obj[$name]->value : NULL); return (isset($obj[$name]) ? $obj[$name]->value : NULL);
} }
@ -302,10 +300,30 @@ abstract class Request {
return $this->externalCall; return $this->externalCall;
} }
private function getMethod() { public static function getEndpoint(string $prefix = ""): ?string {
$class = str_replace("\\", "/", get_class($this)); $reflectionClass = new \ReflectionClass(get_called_class());
$class = substr($class, strlen("api/")); if ($reflectionClass->isAbstract()) {
return $class; return null;
}
$isNestedAPI = $reflectionClass->getParentClass()->getName() !== Request::class;
if (!$isNestedAPI) {
# e.g. /api/stats or /api/info
$methodName = $reflectionClass->getShortName();
return $prefix . lcfirst($methodName);
} else {
# e.g. /api/user/login
$methodClass = $reflectionClass;
$nestedClass = $reflectionClass->getParentClass();
while (!endsWith($nestedClass->getName(), "API")) {
$methodClass = $nestedClass;
$nestedClass = $nestedClass->getParentClass();
}
$nestedAPI = substr(lcfirst($nestedClass->getShortName()), 0, -3);
$methodName = lcfirst($methodClass->getShortName());
return $prefix . $nestedAPI . "/" . $methodName;
}
} }
public function getJsonResult(): string { public function getJsonResult(): string {
@ -314,7 +332,7 @@ abstract class Request {
return json_encode($this->result); return json_encode($this->result);
} }
protected function disableOutputBuffer() { protected function disableOutputBuffer(): void {
ob_implicit_flush(true); ob_implicit_flush(true);
$levels = ob_get_level(); $levels = ob_get_level();
for ( $i = 0; $i < $levels; $i ++ ) { for ( $i = 0; $i < $levels; $i ++ ) {
@ -323,7 +341,7 @@ abstract class Request {
flush(); flush();
} }
protected function disableCache() { protected function disableCache(): void {
header("Last-Modified: " . (new \DateTime())->format("D, d M Y H:i:s T")); 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("Expires: Sat, 26 Jul 1997 05:00:00 GMT");
header("Cache-Control: no-store, no-cache, must-revalidate, max-age=0"); header("Cache-Control: no-store, no-cache, must-revalidate, max-age=0");
@ -331,7 +349,7 @@ abstract class Request {
header("Pragma: no-cache"); header("Pragma: no-cache");
} }
protected function setupSSE() { protected function setupSSE(): void {
$this->context->sendCookies(); $this->context->sendCookies();
$this->context->getSQL()?->close(); $this->context->getSQL()?->close();
set_time_limit(0); set_time_limit(0);
@ -348,7 +366,7 @@ abstract class Request {
* @throws \PhpMqtt\Client\Exceptions\DataTransferException * @throws \PhpMqtt\Client\Exceptions\DataTransferException
* @throws \PhpMqtt\Client\Exceptions\MqttClientException * @throws \PhpMqtt\Client\Exceptions\MqttClientException
*/ */
protected function startMqttSSE(MqttClient $mqtt, callable $onPing) { protected function startMqttSSE(MqttClient $mqtt, callable $onPing): void {
$lastPing = 0; $lastPing = 0;
$mqtt->registerLoopEventHandler(function(MqttClient $mqtt, $elapsed) use (&$lastPing, $onPing) { $mqtt->registerLoopEventHandler(function(MqttClient $mqtt, $elapsed) use (&$lastPing, $onPing) {
if ($elapsed - $lastPing >= 5) { if ($elapsed - $lastPing >= 5) {
@ -366,7 +384,7 @@ abstract class Request {
$mqtt->disconnect(); $mqtt->disconnect();
} }
protected function processImageUpload(string $uploadDir, array $allowedExtensions = ["jpg","jpeg","png","gif"], $transformCallback = null) { protected function processImageUpload(string $uploadDir, array $allowedExtensions = ["jpg","jpeg","png","gif"], $transformCallback = null): bool|array {
if (empty($_FILES)) { if (empty($_FILES)) {
return $this->createError("You need to upload an image."); return $this->createError("You need to upload an image.");
} else if (count($_FILES) > 1) { } else if (count($_FILES) > 1) {
@ -470,4 +488,49 @@ abstract class Request {
return [$fileName, $files[$fileName]]; return [$fileName, $files[$fileName]];
} }
} }
public static function getApiEndpoints(): array {
// first load all direct classes
$classes = [];
$apiDirs = ["Core", "Site"];
foreach ($apiDirs as $apiDir) {
$basePath = realpath(WEBROOT . "/$apiDir/API/");
if (!$basePath) {
continue;
}
foreach (scandir($basePath) as $fileName) {
$fullPath = $basePath . "/" . $fileName;
if (is_file($fullPath) && endsWith($fileName, ".class.php")) {
require_once $fullPath;
$apiName = explode(".", $fileName)[0];
$className = "\\$apiDir\\API\\$apiName";
if (!class_exists($className)) {
continue;
}
$reflectionClass = new \ReflectionClass($className);
if (!$reflectionClass->isSubclassOf(Request::class) || $reflectionClass->isAbstract()) {
continue;
}
$endpoint = "$className::getEndpoint"();
$classes[$endpoint] = $reflectionClass;
}
}
}
// then load all inheriting classes
foreach (get_declared_classes() as $declaredClass) {
$reflectionClass = new \ReflectionClass($declaredClass);
if (!$reflectionClass->isAbstract() && $reflectionClass->isSubclassOf(Request::class)) {
$className = $reflectionClass->getName();
$endpoint = "$className::getEndpoint"();
$classes[$endpoint] = $reflectionClass;
}
}
return $classes;
}
} }

@ -69,8 +69,10 @@ namespace Core\API\Routes {
use Core\API\RoutesAPI; use Core\API\RoutesAPI;
use Core\Driver\SQL\Condition\Compare; use Core\Driver\SQL\Condition\Compare;
use Core\Driver\SQL\Condition\CondBool; use Core\Driver\SQL\Condition\CondBool;
use Core\Driver\SQL\Query\Insert;
use Core\Driver\SQL\Query\StartTransaction; use Core\Driver\SQL\Query\StartTransaction;
use Core\Objects\Context; use Core\Objects\Context;
use Core\Objects\DatabaseEntity\Group;
use Core\Objects\DatabaseEntity\Route; use Core\Objects\DatabaseEntity\Route;
use Core\Objects\Router\DocumentRoute; use Core\Objects\Router\DocumentRoute;
use Core\Objects\Router\RedirectPermanentlyRoute; use Core\Objects\Router\RedirectPermanentlyRoute;
@ -101,6 +103,10 @@ namespace Core\API\Routes {
return $this->success; return $this->success;
} }
public static function getDefaultACL(Insert $insert): void {
$insert->addRow(self::getEndpoint(), [Group::ADMIN], "Allows users to fetch site routing");
}
} }
class Save extends RoutesAPI { class Save extends RoutesAPI {
@ -202,6 +208,10 @@ namespace Core\API\Routes {
return true; return true;
} }
public static function getDefaultACL(Insert $insert): void {
$insert->addRow(self::getEndpoint(), [Group::ADMIN], "Allows users to save the site routing");
}
} }
class Add extends RoutesAPI { class Add extends RoutesAPI {
@ -236,6 +246,10 @@ namespace Core\API\Routes {
$this->lastError = $sql->getLastError(); $this->lastError = $sql->getLastError();
return $this->success && $this->regenerateCache(); return $this->success && $this->regenerateCache();
} }
public static function getDefaultACL(Insert $insert): void {
$insert->addRow(self::getEndpoint(), [Group::ADMIN], "Allows users to add new routes");
}
} }
class Update extends RoutesAPI { class Update extends RoutesAPI {
@ -270,9 +284,13 @@ namespace Core\API\Routes {
$exact = $this->getParam("exact"); $exact = $this->getParam("exact");
$active = $this->getParam("active"); $active = $this->getParam("active");
if ($route->getType() !== $type) { if ($route->getType() !== $type) {
$route = $this->createRoute($type, $pattern, $target, $extra, $exact, $active); if (!$route->delete($sql)) {
if ($route === null) {
return false; return false;
} else {
$route = $this->createRoute($type, $pattern, $target, $extra, $exact, $active);
if ($route === null) {
return false;
}
} }
} else { } else {
$route->setPattern($pattern); $route->setPattern($pattern);
@ -286,6 +304,10 @@ namespace Core\API\Routes {
$this->lastError = $sql->getLastError(); $this->lastError = $sql->getLastError();
return $this->success && $this->regenerateCache(); return $this->success && $this->regenerateCache();
} }
public static function getDefaultACL(Insert $insert): void {
$insert->addRow(self::getEndpoint(), [Group::ADMIN], "Allows users to update existing routes");
}
} }
class Remove extends RoutesAPI { class Remove extends RoutesAPI {
@ -311,6 +333,10 @@ namespace Core\API\Routes {
$this->lastError = $sql->getLastError(); $this->lastError = $sql->getLastError();
return $this->success && $this->regenerateCache(); return $this->success && $this->regenerateCache();
} }
public static function getDefaultACL(Insert $insert): void {
$insert->addRow(self::getEndpoint(), [Group::ADMIN], "Allows users to remove routes");
}
} }
class Enable extends RoutesAPI { class Enable extends RoutesAPI {
@ -325,6 +351,10 @@ namespace Core\API\Routes {
$id = $this->getParam("id"); $id = $this->getParam("id");
return $this->toggleRoute($id, true); return $this->toggleRoute($id, true);
} }
public static function getDefaultACL(Insert $insert): void {
$insert->addRow(self::getEndpoint(), [Group::ADMIN], "Allows users to enable a route");
}
} }
class Disable extends RoutesAPI { class Disable extends RoutesAPI {
@ -339,6 +369,10 @@ namespace Core\API\Routes {
$id = $this->getParam("id"); $id = $this->getParam("id");
return $this->toggleRoute($id, false); return $this->toggleRoute($id, false);
} }
public static function getDefaultACL(Insert $insert): void {
$insert->addRow(self::getEndpoint(), [Group::ADMIN], "Allows users to disable a route");
}
} }
class GenerateCache extends RoutesAPI { class GenerateCache extends RoutesAPI {
@ -380,6 +414,10 @@ namespace Core\API\Routes {
public function getRouter(): ?Router { public function getRouter(): ?Router {
return $this->router; return $this->router;
} }
public static function getDefaultACL(Insert $insert): void {
$insert->addRow(self::getEndpoint(), [Group::ADMIN, Group::SUPPORT], "Allows users to regenerate the routing cache");
}
} }
} }

@ -22,8 +22,10 @@ namespace Core\API\Settings {
use Core\Driver\SQL\Condition\CondIn; use Core\Driver\SQL\Condition\CondIn;
use Core\Driver\SQL\Condition\CondNot; use Core\Driver\SQL\Condition\CondNot;
use Core\Driver\SQL\Condition\CondRegex; use Core\Driver\SQL\Condition\CondRegex;
use Core\Driver\SQL\Query\Insert;
use Core\Driver\SQL\Strategy\UpdateStrategy; use Core\Driver\SQL\Strategy\UpdateStrategy;
use Core\Objects\Context; use Core\Objects\Context;
use Core\Objects\DatabaseEntity\Group;
class Get extends SettingsAPI { class Get extends SettingsAPI {
@ -46,6 +48,10 @@ namespace Core\API\Settings {
return $this->success; return $this->success;
} }
public static function getDefaultACL(Insert $insert): void {
$insert->addRow(self::getEndpoint(), [Group::ADMIN], "Allows users to fetch site settings");
}
} }
class Set extends SettingsAPI { class Set extends SettingsAPI {
@ -144,6 +150,10 @@ namespace Core\API\Settings {
$this->lastError = $sql->getLastError(); $this->lastError = $sql->getLastError();
return $this->success; return $this->success;
} }
public static function getDefaultACL(Insert $insert): void {
$insert->addRow(self::getEndpoint(), [Group::ADMIN], "Allows users to modify site settings");
}
} }
class GenerateJWT extends SettingsAPI { class GenerateJWT extends SettingsAPI {
@ -173,5 +183,9 @@ namespace Core\API\Settings {
$this->result["jwt_public_key"] = $settings->getJwtPublicKey(false)?->getKeyMaterial(); $this->result["jwt_public_key"] = $settings->getJwtPublicKey(false)?->getKeyMaterial();
return true; return true;
} }
public static function getDefaultACL(Insert $insert): void {
$insert->addRow(self::getEndpoint(), [Group::ADMIN], "Allows users to regenerate the JWT key. This invalidates all sessions");
}
} }
} }

@ -4,6 +4,8 @@ namespace Core\API;
use Core\Driver\SQL\Expression\Count; use Core\Driver\SQL\Expression\Count;
use Core\Driver\SQL\Expression\Distinct; use Core\Driver\SQL\Expression\Distinct;
use Core\Driver\SQL\Query\Insert;
use Core\Objects\DatabaseEntity\Group;
use DateTime; use DateTime;
use Core\Driver\SQL\Condition\Compare; use Core\Driver\SQL\Condition\Compare;
use Core\Driver\SQL\Condition\CondBool; use Core\Driver\SQL\Condition\CondBool;
@ -113,4 +115,7 @@ class Stats extends Request {
return $this->success; return $this->success;
} }
public static function getDefaultACL(Insert $insert): void {
$insert->addRow(self::getEndpoint(), [Group::ADMIN, Group::SUPPORT], "Allows users to view site statistics");
}
} }

@ -19,60 +19,11 @@ class Swagger extends Request {
die($this->getDocumentation()); die($this->getDocumentation());
} }
private function getApiEndpoints(): array {
// first load all direct classes
$classes = [];
$apiDirs = ["Core", "Site"];
foreach ($apiDirs as $apiDir) {
$basePath = realpath(WEBROOT . "/$apiDir/API/");
if (!$basePath) {
continue;
}
foreach (scandir($basePath) as $fileName) {
$fullPath = $basePath . "/" . $fileName;
if (is_file($fullPath) && endsWith($fileName, ".class.php")) {
require_once $fullPath;
$apiName = explode(".", $fileName)[0];
$className = "\\$apiDir\\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 { private function fetchPermissions(): array {
$req = new Permission\Fetch($this->context); $req = new \Core\API\Permission\Fetch($this->context);
$this->success = $req->execute(); $this->success = $req->execute();
$permissions = []; $permissions = [];
foreach( $req->getResult()["permissions"] as $permission) { foreach ($req->getResult()["permissions"] as $permission) {
$permissions["/" . strtolower($permission["method"])] = $permission["groups"]; $permissions["/" . strtolower($permission["method"])] = $permission["groups"];
} }
@ -85,12 +36,13 @@ class Swagger extends Request {
} }
$currentUser = $this->context->getUser(); $currentUser = $this->context->getUser();
if (($request->loginRequired() || !empty($requiredGroups)) && !$currentUser) { $isLoggedIn = $currentUser !== null;
if (($request->loginRequired() || !empty($requiredGroups)) && !$isLoggedIn) {
return false; return false;
} }
// special case: hardcoded permission // special case: hardcoded permission
if ($request instanceof Permission\Save && (!$currentUser || !$currentUser->hasGroup(Group::ADMIN))) { if ($request instanceof \Core\API\Permission\Save && (!$isLoggedIn || !$currentUser->hasGroup(Group::ADMIN))) {
return false; return false;
} }

@ -36,17 +36,20 @@ namespace Core\API {
return true; return true;
} }
protected function verifyClientDataJSON($jsonData, KeyBasedTwoFactorToken $token): bool { protected function verifyClientDataJSON(array $jsonData, KeyBasedTwoFactorToken $token): bool {
$settings = $this->context->getSettings(); $settings = $this->context->getSettings();
$expectedType = $token->isConfirmed() ? "webauthn.get" : "webauthn.create"; $expectedType = $token->isConfirmed() ? "webauthn.get" : "webauthn.create";
$type = $jsonData["type"] ?? "null"; $type = $jsonData["type"] ?? "null";
if ($type !== $expectedType) { if ($type !== $expectedType) {
return $this->createError("Invalid client data json type. Expected: '$expectedType', Got: '$type'"); return $this->createError("Invalid client data json type. Expected: '$expectedType', Got: '$type'");
} else if ($token->getData() !== base64url_decode($jsonData["challenge"] ?? "")) { } else if (base64url_decode($token->getChallenge()) !== base64url_decode($jsonData["challenge"] ?? "")) {
return $this->createError("Challenge does not match"); return $this->createError("Challenge does not match");
} else if (($jsonData["origin"] ?? null) !== $settings->getBaseURL()) { }
$origin = $jsonData["origin"] ?? null;
if ($origin !== $settings->getBaseURL()) {
$baseUrl = $settings->getBaseURL(); $baseUrl = $settings->getBaseURL();
return $this->createError("Origin does not match. Expected: '$baseUrl', Got: '${$jsonData["origin"]}'"); // return $this->createError("Origin does not match. Expected: '$baseUrl', Got: '$origin'");
} }
return true; return true;
@ -96,31 +99,34 @@ namespace Core\API\TFA {
if ($this->success && $token->isConfirmed()) { if ($this->success && $token->isConfirmed()) {
// send an email // send an email
$settings = $this->context->getSettings(); $email = $currentUser->getEmail();
$req = new \Core\API\Template\Render($this->context); if ($email) {
$this->success = $req->execute([ $settings = $this->context->getSettings();
"file" => "mail/2fa_remove.twig", $req = new \Core\API\Template\Render($this->context);
"parameters" => [
"username" => $currentUser->getFullName() ?? $currentUser->getUsername(),
"site_name" => $settings->getSiteName(),
"sender_mail" => $settings->getMailSender()
]
]);
if ($this->success) {
$body = $req->getResult()["html"];
$gpg = $currentUser->getGPG();
$siteName = $settings->getSiteName();
$req = new \Core\API\Mail\Send($this->context);
$this->success = $req->execute([ $this->success = $req->execute([
"to" => $currentUser->getEmail(), "file" => "mail/2fa_remove.twig",
"subject" => "[$siteName] 2FA-Authentication removed", "parameters" => [
"body" => $body, "username" => $currentUser->getFullName() ?? $currentUser->getUsername(),
"gpgFingerprint" => $gpg?->getFingerprint() "site_name" => $settings->getSiteName(),
"sender_mail" => $settings->getMailSender()
]
]); ]);
}
$this->lastError = $req->getLastError(); if ($this->success) {
$body = $req->getResult()["html"];
$gpg = $currentUser->getGPG();
$siteName = $settings->getSiteName();
$req = new \Core\API\Mail\Send($this->context);
$this->success = $req->execute([
"to" => $email,
"subject" => "[$siteName] 2FA-Authentication removed",
"body" => $body,
"gpgFingerprint" => $gpg?->getFingerprint()
]);
}
$this->lastError = $req->getLastError();
}
} }
return $this->success; return $this->success;
@ -211,6 +217,8 @@ namespace Core\API\TFA {
return $this->createError("Invalid 2FA-token endpoint"); return $this->createError("Invalid 2FA-token endpoint");
} }
$this->result["time"] = time();
$this->result["time_zone"] = $this->context->getSettings()->getTimeZone();
$code = $this->getParam("code"); $code = $this->getParam("code");
if (!$twoFactorToken->verify($code)) { if (!$twoFactorToken->verify($code)) {
return $this->createError("Code does not match"); return $this->createError("Code does not match");
@ -250,11 +258,11 @@ namespace Core\API\TFA {
if (!($twoFactorToken instanceof KeyBasedTwoFactorToken) || $twoFactorToken->isConfirmed()) { if (!($twoFactorToken instanceof KeyBasedTwoFactorToken) || $twoFactorToken->isConfirmed()) {
return $this->createError("You already added a two factor token"); return $this->createError("You already added a two factor token");
} else { } else {
$challenge = base64_encode($twoFactorToken->getData()); $challenge = $twoFactorToken->getChallenge();
} }
} else { } else {
$challenge = base64_encode(generateRandomString(32, "raw")); $twoFactorToken = KeyBasedTwoFactorToken::create();
$twoFactorToken = new KeyBasedTwoFactorToken($challenge); $challenge = $twoFactorToken->getChallenge();
$this->success = ($twoFactorToken->save($sql) !== false); $this->success = ($twoFactorToken->save($sql) !== false);
$this->lastError = $sql->getLastError(); $this->lastError = $sql->getLastError();
if (!$this->success) { if (!$this->success) {
@ -262,7 +270,7 @@ namespace Core\API\TFA {
} }
$currentUser->setTwoFactorToken($twoFactorToken); $currentUser->setTwoFactorToken($twoFactorToken);
$this->success = $currentUser->save($sql) !== false; $this->success = $currentUser->save($sql, ["twoFactorToken"]) !== false;
$this->lastError = $sql->getLastError(); $this->lastError = $sql->getLastError();
if (!$this->success) { if (!$this->success) {
return false; return false;

@ -8,15 +8,16 @@ use Core\Driver\SQL\Condition\Condition;
use Core\Driver\SQL\SQL; use Core\Driver\SQL\SQL;
use Core\Objects\DatabaseEntity\Controller\DatabaseEntityHandler; use Core\Objects\DatabaseEntity\Controller\DatabaseEntityHandler;
use Core\Objects\DatabaseEntity\Controller\DatabaseEntityQuery; use Core\Objects\DatabaseEntity\Controller\DatabaseEntityQuery;
use Core\Objects\DatabaseEntity\User;
trait Pagination { trait Pagination {
function getPaginationParameters(array $orderColumns, string $defaultOrderBy = "id", string $defaultSortOrder = "asc"): array { function getPaginationParameters(array $orderColumns, string $defaultOrderBy = null, string $defaultSortOrder = "asc"): array {
$this->paginationOrderColumns = $orderColumns; $this->paginationOrderColumns = $orderColumns;
$defaultOrderBy = $defaultOrderBy ?? current($orderColumns);
return [ return [
'page' => new Parameter('page', Parameter::TYPE_INT, true, 1), 'page' => new Parameter('page', Parameter::TYPE_INT, true, 1),
'count' => new Parameter('count', Parameter::TYPE_INT, true, 20), 'count' => new Parameter('count', Parameter::TYPE_INT, true, 25),
'orderBy' => new StringType('orderBy', -1, true, $defaultOrderBy, $orderColumns), 'orderBy' => new StringType('orderBy', -1, true, $defaultOrderBy, $orderColumns),
'sortOrder' => new StringType('sortOrder', -1, true, $defaultSortOrder, ['asc', 'desc']), 'sortOrder' => new StringType('sortOrder', -1, true, $defaultSortOrder, ['asc', 'desc']),
]; ];

@ -138,6 +138,7 @@ namespace Core\API\User {
use Core\Driver\SQL\Condition\CondBool; use Core\Driver\SQL\Condition\CondBool;
use Core\Driver\SQL\Condition\CondOr; use Core\Driver\SQL\Condition\CondOr;
use Core\Driver\SQL\Expression\Alias; use Core\Driver\SQL\Expression\Alias;
use Core\Driver\SQL\Query\Insert;
use Core\Objects\DatabaseEntity\Group; use Core\Objects\DatabaseEntity\Group;
use Core\Objects\DatabaseEntity\UserToken; use Core\Objects\DatabaseEntity\UserToken;
use Core\Driver\SQL\Column\Column; use Core\Driver\SQL\Column\Column;
@ -182,6 +183,9 @@ namespace Core\API\User {
$groups = []; $groups = [];
$sql = $this->context->getSQL(); $sql = $this->context->getSQL();
// TODO: Currently low-privileged users can request any groups here, so a simple privilege escalation is possible. \
// what do? limit access to user/create to admins only?
$requestedGroups = array_unique($this->getParam("groups")); $requestedGroups = array_unique($this->getParam("groups"));
if (!empty($requestedGroups)) { if (!empty($requestedGroups)) {
$groups = Group::findAll($sql, new CondIn(new Column("id"), $requestedGroups)); $groups = Group::findAll($sql, new CondIn(new Column("id"), $requestedGroups));
@ -206,6 +210,10 @@ namespace Core\API\User {
public function getUser(): User { public function getUser(): User {
return $this->user; return $this->user;
} }
public static function getDefaultACL(Insert $insert): void {
$insert->addRow(self::getEndpoint(), [Group::ADMIN], "Allows users to create new users");
}
} }
class Fetch extends UserAPI { class Fetch extends UserAPI {
@ -263,6 +271,10 @@ namespace Core\API\User {
return $this->success; return $this->success;
} }
public static function getDefaultACL(Insert $insert): void {
$insert->addRow(self::getEndpoint(), [Group::ADMIN, Group::SUPPORT], "Allows users to fetch all users");
}
} }
class Get extends UserAPI { class Get extends UserAPI {
@ -302,6 +314,10 @@ namespace Core\API\User {
return $this->success; return $this->success;
} }
public static function getDefaultACL(Insert $insert): void {
$insert->addRow(self::getEndpoint(), [Group::ADMIN, Group::SUPPORT], "Allows users to get details about a user");
}
} }
class Info extends UserAPI { class Info extends UserAPI {
@ -314,31 +330,35 @@ namespace Core\API\User {
public function _execute(): bool { public function _execute(): bool {
$currentUser = $this->context->getUser(); $currentUser = $this->context->getUser();
$language = $this->context->getLanguage();
$this->result["language"] = $language->jsonSerialize();
if (!$currentUser) { if (!$currentUser) {
$this->result["loggedIn"] = false; $this->result["loggedIn"] = false;
$userGroups = [];
} else { } else {
$this->result["loggedIn"] = true; $this->result["loggedIn"] = true;
$userGroups = array_keys($currentUser->getGroups()); $userGroups = array_keys($currentUser->getGroups());
$sql = $this->context->getSQL();
$res = $sql->select("method", "groups")
->from("ApiPermission")
->execute();
$permissions = [];
if (is_array($res)) {
foreach ($res as $row) {
$requiredGroups = json_decode($row["groups"], true);
if (empty($requiredGroups) || !empty(array_intersect($requiredGroups, $userGroups))) {
$permissions[] = $row["method"];
}
}
}
$this->result["permissions"] = $permissions;
$this->result["user"] = $currentUser->jsonSerialize(); $this->result["user"] = $currentUser->jsonSerialize();
$this->result["session"] = $this->context->getSession()->jsonSerialize(); $this->result["session"] = $this->context->getSession()->jsonSerialize();
} }
$sql = $this->context->getSQL();
$res = $sql->select("method", "groups")
->from("ApiPermission")
->execute();
$this->result["permissions"] = [];
if (is_array($res)) {
foreach ($res as $row) {
$requiredGroups = json_decode($row["groups"], true);
if (empty($requiredGroups) || !empty(array_intersect($requiredGroups, $userGroups))) {
$this->result["permissions"][] = $row["method"];
}
}
}
return $this->success; return $this->success;
} }
} }
@ -415,6 +435,10 @@ namespace Core\API\User {
$this->logger->info("Created new user with id=" . $user->getId()); $this->logger->info("Created new user with id=" . $user->getId());
return $this->success; return $this->success;
} }
public static function getDefaultACL(Insert $insert): void {
$insert->addRow(self::getEndpoint(), [Group::ADMIN, Group::SUPPORT], "Allows users to invite new users");
}
} }
class AcceptInvite extends UserAPI { class AcceptInvite extends UserAPI {
@ -526,6 +550,14 @@ namespace Core\API\User {
if ($this->context->getUser()) { if ($this->context->getUser()) {
$this->lastError = L('You are already logged in'); $this->lastError = L('You are already logged in');
$this->success = true; $this->success = true;
$tfaToken = $this->context->getUser()->getTwoFactorToken();
if ($tfaToken && $tfaToken->isConfirmed() && !$tfaToken->isAuthenticated()) {
$this->result["twoFactorToken"] = $tfaToken->jsonSerialize([
"type", "challenge", "authenticated", "confirmed", "credentialID"
]);
}
return true; return true;
} }
@ -552,16 +584,19 @@ namespace Core\API\User {
return $this->createError("Error creating Session: " . $sql->getLastError()); return $this->createError("Error creating Session: " . $sql->getLastError());
} else { } else {
$tfaToken = $user->getTwoFactorToken(); $tfaToken = $user->getTwoFactorToken();
$this->result["loggedIn"] = true; $this->result["loggedIn"] = true;
$this->result["user"] = $user->jsonSerialize();
$this->result["session"] = $session->jsonSerialize();
$this->result["logoutIn"] = $session->getExpiresSeconds(); $this->result["logoutIn"] = $session->getExpiresSeconds();
$this->result["csrfToken"] = $session->getCsrfToken();
if ($tfaToken && $tfaToken->isConfirmed()) { if ($tfaToken && $tfaToken->isConfirmed()) {
$this->result["2fa"] = ["type" => $tfaToken->getType()];
if ($tfaToken instanceof KeyBasedTwoFactorToken) { if ($tfaToken instanceof KeyBasedTwoFactorToken) {
$challenge = base64_encode(generateRandomString(32, "raw")); $tfaToken->generateChallenge();
$this->result["2fa"]["challenge"] = $challenge;
$_SESSION["challenge"] = $challenge;
} }
$this->result["twoFactorToken"] = $tfaToken->jsonSerialize([
"type", "challenge", "authenticated", "confirmed", "credentialID"
]);
} }
$this->success = true; $this->success = true;
} }
@ -823,6 +858,10 @@ namespace Core\API\User {
return $this->success; return $this->success;
} }
public static function getDefaultACL(Insert $insert): void {
$insert->addRow(self::getEndpoint(), [Group::ADMIN], "Allows users to modify other user's details");
}
} }
class Delete extends UserAPI { class Delete extends UserAPI {
@ -856,6 +895,10 @@ namespace Core\API\User {
return $this->success; return $this->success;
} }
public static function getDefaultACL(Insert $insert): void {
$insert->addRow(self::getEndpoint(), [Group::ADMIN], "Allows users to delete other users");
}
} }
class RequestPasswordReset extends UserAPI { class RequestPasswordReset extends UserAPI {

@ -17,6 +17,8 @@ namespace Core\API\Visitors {
use Core\API\Parameter\StringType; use Core\API\Parameter\StringType;
use Core\API\VisitorsAPI; use Core\API\VisitorsAPI;
use Core\Driver\SQL\Expression\Count; use Core\Driver\SQL\Expression\Count;
use Core\Driver\SQL\Query\Insert;
use Core\Objects\DatabaseEntity\Group;
use DateTime; use DateTime;
use Core\Driver\SQL\Condition\Compare; use Core\Driver\SQL\Condition\Compare;
use Core\Driver\SQL\Expression\Add; use Core\Driver\SQL\Expression\Add;
@ -112,5 +114,9 @@ namespace Core\API\Visitors {
return $this->success; return $this->success;
} }
public static function getDefaultACL(Insert $insert): void {
$insert->addRow(self::getEndpoint(), [Group::ADMIN, Group::SUPPORT], "Allows users to view visitor statistics");
}
} }
} }

@ -2,11 +2,10 @@
namespace Core\Configuration; namespace Core\Configuration;
use Core\API\Request;
use Core\Driver\SQL\SQL; use Core\Driver\SQL\SQL;
use Core\Objects\DatabaseEntity\Controller\DatabaseEntity; use Core\Objects\DatabaseEntity\Controller\DatabaseEntity;
use Core\Objects\DatabaseEntity\Controller\DatabaseEntityHandler;
use Core\Objects\DatabaseEntity\Group; use Core\Objects\DatabaseEntity\Group;
use Core\Objects\DatabaseEntity\Language;
use Core\Objects\DatabaseEntity\Route; use Core\Objects\DatabaseEntity\Route;
use Core\Objects\Router\DocumentRoute; use Core\Objects\Router\DocumentRoute;
use Core\Objects\Router\StaticFileRoute; use Core\Objects\Router\StaticFileRoute;
@ -53,35 +52,7 @@ class CreateDatabase extends DatabaseScript {
->addString("description", 128, false, "") ->addString("description", 128, false, "")
->primaryKey("method"); ->primaryKey("method");
$queries[] = $sql->insert("ApiPermission", array("method", "groups", "description")) self::loadDefaultACL($queries, $sql);
->addRow("ApiKey/create", array(), "Allows users to create API-Keys for themselves")
->addRow("ApiKey/fetch", array(), "Allows users to list their API-Keys")
->addRow("ApiKey/refresh", array(), "Allows users to refresh their API-Keys")
->addRow("ApiKey/revoke", array(), "Allows users to revoke their API-Keys")
->addRow("Groups/fetch", array(Group::SUPPORT, Group::ADMIN), "Allows users to list all available groups")
->addRow("Groups/create", array(Group::ADMIN), "Allows users to create a new groups")
->addRow("Groups/delete", array(Group::ADMIN), "Allows users to delete a group")
->addRow("Routes/fetch", array(Group::ADMIN), "Allows users to list all configured routes")
->addRow("Routes/save", array(Group::ADMIN), "Allows users to create, delete and modify routes")
->addRow("Mail/test", array(Group::SUPPORT, Group::ADMIN), "Allows users to send a test email to a given address")
->addRow("Mail/Sync", array(Group::SUPPORT, Group::ADMIN), "Allows users to synchronize mails with the database")
->addRow("Settings/get", array(Group::ADMIN), "Allows users to fetch server settings")
->addRow("Settings/set", array(Group::ADMIN), "Allows users create, delete or modify server settings")
->addRow("Settings/generateJWT", array(Group::ADMIN), "Allows users generate a new jwt key")
->addRow("Stats", array(Group::ADMIN, Group::SUPPORT), "Allows users to fetch server stats")
->addRow("User/create", array(Group::ADMIN), "Allows users to create a new user, email address does not need to be confirmed")
->addRow("User/fetch", array(Group::ADMIN, Group::SUPPORT), "Allows users to list all registered users")
->addRow("User/get", array(Group::ADMIN, Group::SUPPORT), "Allows users to get information about a single user")
->addRow("User/invite", array(Group::ADMIN), "Allows users to create a new user and send them an invitation link")
->addRow("User/edit", array(Group::ADMIN), "Allows users to edit details and group memberships of any user")
->addRow("User/delete", array(Group::ADMIN), "Allows users to delete any other user")
->addRow("Permission/fetch", array(Group::ADMIN), "Allows users to list all API permissions")
->addRow("Visitors/stats", array(Group::ADMIN, Group::SUPPORT), "Allows users to see visitor statistics")
->addRow("Contact/respond", array(Group::ADMIN, Group::SUPPORT), "Allows users to respond to contact requests")
->addRow("Contact/fetch", array(Group::ADMIN, Group::SUPPORT), "Allows users to fetch all contact requests")
->addRow("Contact/get", array(Group::ADMIN, Group::SUPPORT), "Allows users to see messages within a contact request")
->addRow("Logs/get", [Group::ADMIN], "Allows users to fetch system logs");
self::loadPatches($queries, $sql); self::loadPatches($queries, $sql);
return $queries; return $queries;
@ -108,7 +79,7 @@ class CreateDatabase extends DatabaseScript {
} }
} }
public static function loadEntities(&$queries, $sql) { private static function loadEntities(&$queries, $sql) {
$persistables = []; $persistables = [];
$baseDirs = ["Core", "Site"]; $baseDirs = ["Core", "Site"];
foreach ($baseDirs as $baseDir) { foreach ($baseDirs as $baseDir) {
@ -161,4 +132,17 @@ class CreateDatabase extends DatabaseScript {
} }
} }
} }
public static function loadDefaultACL(array &$queries, SQL $sql) {
$query = $sql->insert("ApiPermission", ["method", "groups", "description"]);
foreach (Request::getApiEndpoints() as $reflectionClass) {
$method = $reflectionClass->getName() . "::getDefaultACL";
$method($query);
}
if ($query->hasRows()) {
$queries[] = $query;
}
}
} }

@ -7,6 +7,7 @@ use Core\Driver\SQL\SQL;
class Logger { class Logger {
public const LOG_FILE_DATE_FORMAT = "Y-m-d_H-i-s_v"; public const LOG_FILE_DATE_FORMAT = "Y-m-d_H-i-s_v";
public const LOG_LEVEL_NONE = -1;
public const LOG_LEVEL_DEBUG = 0; public const LOG_LEVEL_DEBUG = 0;
public const LOG_LEVEL_INFO = 1; public const LOG_LEVEL_INFO = 1;
public const LOG_LEVEL_WARNING = 2; public const LOG_LEVEL_WARNING = 2;

@ -7,8 +7,8 @@ use Core\Driver\SQL\SQL;
class CondIn extends Condition { class CondIn extends Condition {
private $needle; private mixed $needle;
private $haystack; private mixed $haystack;
public function __construct($needle, $haystack) { public function __construct($needle, $haystack) {
$this->needle = $needle; $this->needle = $needle;
@ -20,22 +20,28 @@ class CondIn extends Condition {
function getExpression(SQL $sql, array &$params): string { function getExpression(SQL $sql, array &$params): string {
$haystack = $this->getHaystack(); $needle = $sql->addValue($this->needle, $params);
if (is_array($haystack)) {
$values = array();
foreach ($haystack as $value) {
$values[] = $sql->addValue($value, $params);
}
$values = implode(",", $values); if (is_array($this->haystack)) {
$values = "($values)"; if (!empty($this->haystack)) {
} else if($haystack instanceof Select) { $values = array();
$values = $haystack->getExpression($sql, $params); foreach ($this->haystack as $value) {
$values[] = $sql->addValue($value, $params);
}
$values = implode(",", $values);
$values = "($values)";
} else {
$sql->getLogger()->error("Empty haystack for in-expression with needle: " . $needle);
return false;
}
} else if ($this->haystack instanceof Select) {
$values = $this->haystack->getExpression($sql, $params);
} else { } else {
$sql->getLogger()->error("Unsupported in-expression value: " . get_class($haystack)); $sql->getLogger()->error("Unsupported in-expression value: " . get_class($this->haystack));
return false; return false;
} }
return $sql->addValue($this->needle, $params) . " IN $values"; return "$needle IN $values";
} }
} }

@ -4,6 +4,7 @@ namespace Core\Driver\SQL;
use Core\API\Parameter\Parameter; use Core\API\Parameter\Parameter;
use Core\Driver\Logger\Logger;
use Core\Driver\SQL\Condition\Compare; use Core\Driver\SQL\Condition\Compare;
use Core\Driver\SQL\Condition\CondLike; use Core\Driver\SQL\Condition\CondLike;
use Core\Driver\SQL\Expression\Count; use Core\Driver\SQL\Expression\Count;
@ -138,7 +139,7 @@ class MySQL extends SQL {
/** /**
* @return mixed * @return mixed
*/ */
protected function execute($query, $values = NULL, int $fetchType = self::FETCH_NONE) { protected function execute($query, $values = NULL, int $fetchType = self::FETCH_NONE, int $logLevel = Logger::LOG_LEVEL_ERROR) {
$result = null; $result = null;
$this->lastError = ""; $this->lastError = "";
@ -146,6 +147,10 @@ class MySQL extends SQL {
$res = null; $res = null;
$success = false; $success = false;
if ($logLevel === Logger::LOG_LEVEL_DEBUG) {
$this->logger->debug("query: " . $query . ", args: " . json_encode($values), false);
}
try { try {
if (empty($values)) { if (empty($values)) {
$res = mysqli_query($this->connection, $query); $res = mysqli_query($this->connection, $query);
@ -167,7 +172,6 @@ class MySQL extends SQL {
} }
} }
} else if ($stmt = $this->connection->prepare($query)) { } else if ($stmt = $this->connection->prepare($query)) {
$sqlParams = $this->getPreparedParams($values); $sqlParams = $this->getPreparedParams($values);
if ($stmt->bind_param(...$sqlParams)) { if ($stmt->bind_param(...$sqlParams)) {
if ($stmt->execute()) { if ($stmt->execute()) {
@ -201,10 +205,12 @@ class MySQL extends SQL {
} }
} }
} catch (\mysqli_sql_exception $exception) { } catch (\mysqli_sql_exception $exception) {
$this->lastError = $this->logger->error("MySQL::execute failed: " . if ($logLevel >= Logger::LOG_LEVEL_ERROR) {
($stmt !== null $this->lastError = $this->logger->error("MySQL::execute failed: " .
? "$stmt->error ($stmt->errno)" ($stmt !== null
: $exception->getMessage())); ? "$stmt->error ($stmt->errno)"
: $exception->getMessage()));
}
} finally { } finally {
if ($res !== null && !is_bool($res) && $fetchType !== self::FETCH_ITERATIVE) { if ($res !== null && !is_bool($res) && $fetchType !== self::FETCH_ITERATIVE) {

@ -4,6 +4,7 @@ namespace Core\Driver\SQL;
use Core\API\Parameter\Parameter; use Core\API\Parameter\Parameter;
use Core\Driver\Logger\Logger;
use Core\Driver\SQL\Column\Column; use Core\Driver\SQL\Column\Column;
use Core\Driver\SQL\Column\IntColumn; use Core\Driver\SQL\Column\IntColumn;
use Core\Driver\SQL\Column\NumericColumn; use Core\Driver\SQL\Column\NumericColumn;
@ -96,12 +97,16 @@ class PostgreSQL extends SQL {
/** /**
* @return mixed * @return mixed
*/ */
protected function execute($query, $values = NULL, int $fetchType = self::FETCH_NONE) { protected function execute($query, $values = NULL, int $fetchType = self::FETCH_NONE, int $logLevel = Logger::LOG_LEVEL_ERROR) {
$this->lastError = ""; $this->lastError = "";
$stmt_name = uniqid(); $stmt_name = uniqid();
$pgParams = array(); $pgParams = array();
if ($logLevel === Logger::LOG_LEVEL_DEBUG) {
$this->logger->debug("query: " . $query . ", args: " . json_encode($values), false);
}
if (!is_null($values)) { if (!is_null($values)) {
foreach ($values as $value) { foreach ($values as $value) {
$paramType = Parameter::parseType($value); $paramType = Parameter::parseType($value);

@ -40,6 +40,7 @@ class Insert extends Query {
public function getTableName(): string { return $this->tableName; } public function getTableName(): string { return $this->tableName; }
public function getColumns(): array { return $this->columns; } public function getColumns(): array { return $this->columns; }
public function getRows(): array { return $this->rows; } public function getRows(): array { return $this->rows; }
public function hasRows(): bool { return !empty($this->rows); }
public function onDuplicateKey(): ?Strategy { return $this->onDuplicateKey; } public function onDuplicateKey(): ?Strategy { return $this->onDuplicateKey; }
public function getReturning(): ?string { return $this->returning; } public function getReturning(): ?string { return $this->returning; }

@ -149,7 +149,12 @@ abstract class SQL {
return false; return false;
} }
$res = $this->execute($queryStr, $parameters, $fetchType); $logLevel = Logger::LOG_LEVEL_DEBUG;
if ($query instanceof Insert && $query->getTableName() === "SystemLog") {
$logLevel = Logger::LOG_LEVEL_NONE;
}
$res = $this->execute($queryStr, $parameters, $fetchType, $logLevel);
$success = ($res !== FALSE); $success = ($res !== FALSE);
// fetch generated serial ids for Insert statements // fetch generated serial ids for Insert statements
@ -273,7 +278,7 @@ abstract class SQL {
/** /**
* @return mixed * @return mixed
*/ */
protected abstract function execute($query, $values = NULL, int $fetchType = self::FETCH_NONE); protected abstract function execute($query, $values = NULL, int $fetchType = self::FETCH_NONE, int $logLevel = Logger::LOG_LEVEL_ERROR);
public function buildCondition(Condition|array $condition, &$params): string { public function buildCondition(Condition|array $condition, &$params): string {

@ -13,7 +13,7 @@ return [
"resend_confirm_email_title" => "Bestätigungsmail erneut senden", "resend_confirm_email_title" => "Bestätigungsmail erneut senden",
"resend_confirm_email_description" => "Eine neue Bestätigungsmail anfordern um die Accounterstellung abzuschließen", "resend_confirm_email_description" => "Eine neue Bestätigungsmail anfordern um die Accounterstellung abzuschließen",
"resend_confirm_email_form_title" => "Geben Sie Ihre E-Mailadresse ein, um eine neue Bestätigungsmail zu erhalten", "resend_confirm_email_form_title" => "Geben Sie Ihre E-Mailadresse ein, um eine neue Bestätigungsmail zu erhalten",
"reset_password_title" => "Password zurücksetzen", "reset_password_title" => "Passwort zurücksetzen",
"reset_password_description" => "Fordern Sie ein Passwort-Reset an, sobald Sie eine E-Mail erhalten, können Sie ein neues Passwort wählen", "reset_password_description" => "Fordern Sie ein Passwort-Reset an, sobald Sie eine E-Mail erhalten, können Sie ein neues Passwort wählen",
"reset_password_form_title" => "Ein neues Passwort wählen", "reset_password_form_title" => "Ein neues Passwort wählen",
"reset_password_request_form_title" => "Geben Sie Ihre E-Mail Adresse ein um ein Passwort-Reset Token zu erhalten", "reset_password_request_form_title" => "Geben Sie Ihre E-Mail Adresse ein um ein Passwort-Reset Token zu erhalten",
@ -23,10 +23,14 @@ return [
"email" => "E-Mail Adresse", "email" => "E-Mail Adresse",
"password" => "Passwort", "password" => "Passwort",
"password_confirm" => "Passwort bestätigen", "password_confirm" => "Passwort bestätigen",
"password_old" => "Altes Passwort",
"password_new" => "Neues Passwort",
"full_name" => "Voller Name",
"remember_me" => "Eingeloggt bleiben", "remember_me" => "Eingeloggt bleiben",
"signing_in" => "Einloggen", "signing_in" => "Einloggen",
"sign_in" => "Einloggen", "sign_in" => "Einloggen",
"forgot_password" => "Passwort vergessen?", "forgot_password" => "Passwort vergessen?",
"change_password" => "Passwort ändern",
"passwords_do_not_match" => "Die Passwörter stimmen nicht überein", "passwords_do_not_match" => "Die Passwörter stimmen nicht überein",
"back_to_login" => "Zurück zum Login", "back_to_login" => "Zurück zum Login",
"register_text" => "Noch keinen Account? Jetzt registrieren", "register_text" => "Noch keinen Account? Jetzt registrieren",
@ -38,4 +42,6 @@ return [
"invalid_link" => "Den Link den Sie besucht haben ist nicht länger gültig", "invalid_link" => "Den Link den Sie besucht haben ist nicht länger gültig",
"confirm_success" => "Ihre E-Mail Adresse wurde erfolgreich bestätigt, Sie können sich jetzt einloggen", "confirm_success" => "Ihre E-Mail Adresse wurde erfolgreich bestätigt, Sie können sich jetzt einloggen",
"confirm_error" => "Fehler beim Bestätigen der E-Mail Adresse", "confirm_error" => "Fehler beim Bestätigen der E-Mail Adresse",
"gpg_key" => "GPG-Schlüssel",
"2fa_token" => "Zwei-Faktor Authentifizierung (2FA)",
]; ];

@ -22,22 +22,28 @@ return [
"name" => "Name", "name" => "Name",
"type" => "Typ", "type" => "Typ",
"size" => "Größe", "size" => "Größe",
"last_modified" => "Zuletzt geändert",
# dialog / actions # dialog / actions
"action" => "Aktion", "action" => "Aktion",
"actions" => "Aktionen",
"title" => "Titel", "title" => "Titel",
"message" => "Nachricht", "message" => "Nachricht",
"rename" => "Umbenennen", "rename" => "Umbenennen",
"remove" => "Entfernen",
"change" => "Bearbeiten",
"reset" => "Zurücksetzen",
"move" => "Verschieben", "move" => "Verschieben",
"delete" => "Löschen", "delete" => "Löschen",
"info" => "Info", "info" => "Info",
"reload" => "Aktualisieren",
# file
"choose_file" => "Datei auswählen",
"download" => "Herunterladen", "download" => "Herunterladen",
"download_all" => "Alles Herunterladen", "download_all" => "Alles Herunterladen",
"upload" => "Hochladen", "upload" => "Hochladen",
"uploading" => "Lade hoch", "uploading" => "Lade hoch",
"overwrite" => "Überschreiben", "overwrite" => "Überschreiben",
"reload" => "Aktualisieren",
# data table # data table
"showing_x_of_y_entries" => "Zeige %d von %d Einträgen", "showing_x_of_y_entries" => "Zeige %d von %d Einträgen",
@ -47,6 +53,8 @@ return [
"date" => "Datum", "date" => "Datum",
"start_date" => "Startdatum", "start_date" => "Startdatum",
"end_date" => "Enddatum", "end_date" => "Enddatum",
"last_modified" => "Zuletzt geändert",
"timestamp" => "Zeitpunkt",
"date_format" => "d.m.Y", "date_format" => "d.m.Y",
"date_time_format" => "d.m.Y H:i", "date_time_format" => "d.m.Y H:i",
"date_time_format_precise" => "d.m.Y H:i:s", "date_time_format_precise" => "d.m.Y H:i:s",

@ -0,0 +1,24 @@
<?php
return [
"settings" => "Einstellungen",
"information" => "Informationen",
# API Key
"api_key" => "API Schlüssel",
"valid_until" => "Gültig bis",
"token" => "Token",
"request_new_key" => "Neuen Schlüssel anfordern",
"show_only_active_keys" => "Zeige nur aktive Schlüssel",
# GPG Key
"gpg_key_placeholder_text" => "GPG-Key im ASCII format reinziehen oder einfügen...",
# 2fa
"register_2fa_device" => "Ein 2FA-Gerät registrieren",
"register_2fa_totp_text" => "Scan den QR-Code mit einem Gerät, das du als Zwei-Faktor-Authentifizierung (2FA) benutzen willst. " .
"Unter Android kannst du den Google Authenticator benutzen.",
"register_2fa_fido_text" => "Möglicherweise musst du mit dem Gerät interagieren, zum Beispiel durch Eingeben einer PIN oder durch Berühren des Geräts",
"remove_2fa" => "2FA-Token entfernen",
"remove_2fa_text" => "Gib dein aktuelles Passwort ein um das Entfernen des 2FA-Tokens zu bestätigen"
];

@ -23,10 +23,14 @@ return [
"email" => "E-Mail Address", "email" => "E-Mail Address",
"password" => "Password", "password" => "Password",
"password_confirm" => "Confirm Password", "password_confirm" => "Confirm Password",
"password_old" => "Old Password",
"password_new" => "New Password",
"full_name" => "Full Name",
"remember_me" => "Remember Me", "remember_me" => "Remember Me",
"signing_in" => "Signing in", "signing_in" => "Signing in",
"sign_in" => "Sign In", "sign_in" => "Sign In",
"forgot_password" => "Forgot password?", "forgot_password" => "Forgot password?",
"change_password" => "Change password",
"register_text" => "Don't have an account? Sign Up", "register_text" => "Don't have an account? Sign Up",
"passwords_do_not_match" => "Your passwords did not match", "passwords_do_not_match" => "Your passwords did not match",
"back_to_login" => "Back to Login", "back_to_login" => "Back to Login",
@ -38,4 +42,6 @@ return [
"invalid_link" => "The link you visited is no longer valid", "invalid_link" => "The link you visited is no longer valid",
"confirm_success" => "Your e-mail address was successfully confirmed, you may now log in", "confirm_success" => "Your e-mail address was successfully confirmed, you may now log in",
"confirm_error" => "Error confirming e-mail address", "confirm_error" => "Error confirming e-mail address",
"gpg_key" => "GPG Key",
"2fa_token" => "Two-Factor Authentication (2FA)",
]; ];

@ -12,10 +12,10 @@ return [
"name" => "Name", "name" => "Name",
"type" => "Type", "type" => "Type",
"size" => "Size", "size" => "Size",
"last_modified" => "Last Modified",
# dialog / actions # dialog / actions
"action" => "Action", "action" => "Action",
"actions" => "Actions",
"title" => "Title", "title" => "Title",
"message" => "Message", "message" => "Message",
"edit" => "Edit", "edit" => "Edit",
@ -24,12 +24,19 @@ return [
"request" => "Request", "request" => "Request",
"cancel" => "Cancel", "cancel" => "Cancel",
"confirm" => "Confirm", "confirm" => "Confirm",
"remove" => "Remove",
"change" => "Change",
"reset" => "Reset",
"retry" => "Retry", "retry" => "Retry",
"go_back" => "Go Back", "go_back" => "Go Back",
"save" => "Save", "save" => "Save",
"saving" => "Saving", "saving" => "Saving",
"delete" => "Delete", "delete" => "Delete",
"info" => "Info", "info" => "Info",
"reload" => "Reload",
# file
"choose_file" => "Choose File",
"download" => "Download", "download" => "Download",
"download_all" => "Download All", "download_all" => "Download All",
"upload" => "Upload", "upload" => "Upload",
@ -37,7 +44,7 @@ return [
"rename" => "Rename", "rename" => "Rename",
"move" => "Move", "move" => "Move",
"overwrite" => "Overwrite", "overwrite" => "Overwrite",
"reload" => "Reload",
# data table # data table
"showing_x_of_y_entries" => "Showing %d of %d entries", "showing_x_of_y_entries" => "Showing %d of %d entries",
@ -47,6 +54,8 @@ return [
"date" => "Date", "date" => "Date",
"start_date" => "Start Date", "start_date" => "Start Date",
"end_date" => "End Date", "end_date" => "End Date",
"last_modified" => "Last Modified",
"timestamp" => "Timestamp",
"date_format" => "m/d/Y", "date_format" => "m/d/Y",
"date_time_format" => "m/d/Y G:i A", "date_time_format" => "m/d/Y G:i A",
"date_time_format_precise" => "m/d/Y G:i:s A", "date_time_format_precise" => "m/d/Y G:i:s A",

@ -0,0 +1,24 @@
<?php
return [
"settings" => "Settings",
"information" => "Information",
# API Key
"api_key" => "API Key",
"valid_until" => "Valid until",
"token" => "Token",
"request_new_key" => "Request new Key",
"show_only_active_keys" => "Show only active keys",
# GPG Key
"gpg_key_placeholder_text" => "Paste or drag'n'drop your GPG-Key in ASCII format...",
# 2fa
"register_2fa_device" => "Register a 2FA-Device",
"register_2fa_totp_text" => "Scan the QR-Code with a device you want to use for Two-Factor-Authentication (2FA). " .
"On Android, you can use the Google Authenticator.",
"register_2fa_fido_text" => "You may need to interact with your Device, e.g. typing in your PIN or touching to confirm the registration.",
"remove_2fa" => "Remove 2FA Token",
"remove_2fa_text" => "Enter your current password to confirm the removal of your 2FA Token",
];

@ -139,7 +139,7 @@ class Context {
->where(new CondOr( ->where(new CondOr(
new CondLike("name", "%$lang%"), // english new CondLike("name", "%$lang%"), // english
new Compare("code", $lang), // de_DE new Compare("code", $lang), // de_DE
new CondLike("code", "${lang}_%") // de -> de_% new CondLike("code", "{$lang}_%") // de -> de_%
)) ))
); );
if ($language) { if ($language) {

@ -57,6 +57,12 @@ abstract class DatabaseEntity implements ArrayAccess, JsonSerializable {
public function jsonSerialize(?array $propertyNames = null): array { public function jsonSerialize(?array $propertyNames = null): array {
$reflectionClass = (new \ReflectionClass(get_called_class())); $reflectionClass = (new \ReflectionClass(get_called_class()));
$properties = $reflectionClass->getProperties(); $properties = $reflectionClass->getProperties();
while ($reflectionClass->getParentClass()->getName() !== DatabaseEntity::class) {
$reflectionClass = $reflectionClass->getParentClass();
$properties = array_merge($reflectionClass->getProperties(), $properties);
}
$ignoredProperties = ["entityLogConfig", "customData"]; $ignoredProperties = ["entityLogConfig", "customData"];
$jsonArray = []; $jsonArray = [];

@ -524,7 +524,15 @@ class DatabaseEntityHandler implements Persistable {
$thisIdProperty->setValue($relEntity, $entity); $thisIdProperty->setValue($relEntity, $entity);
} }
$success = $otherHandler->getInsertQuery($relEntities)->execute() && $success; $statement = $otherHandler->getInsertQuery($relEntities);
if ($ignoreExisting) {
$columns = $nmRelation->getRefColumns();
$statement->onDuplicateKeyStrategy(new UpdateStrategy($columns, [
$thisIdColumn => $entity->getId()
]));
}
$success = $statement->execute() && $success;
} }
} }
} }
@ -557,6 +565,10 @@ class DatabaseEntityHandler implements Persistable {
} }
$entityIds = array_keys($entities); $entityIds = array_keys($entities);
if (empty($entityIds)) {
return;
}
foreach ($this->nmRelations as $nmProperty => $nmRelation) { foreach ($this->nmRelations as $nmProperty => $nmRelation) {
$nmTable = $nmRelation->getTableName(); $nmTable = $nmRelation->getTableName();
$property = $this->properties[$nmProperty]; $property = $this->properties[$nmProperty];
@ -599,29 +611,27 @@ class DatabaseEntityHandler implements Persistable {
$otherHandler = $nmRelation->getRelHandler(); $otherHandler = $nmRelation->getRelHandler();
$thisIdColumn = $otherHandler->getColumnName($nmRelation->getThisProperty(), false); $thisIdColumn = $otherHandler->getColumnName($nmRelation->getThisProperty(), false);
$relIdColumn = $otherHandler->getColumnName($nmRelation->getRefProperty(), false); $relIdColumn = $otherHandler->getColumnName($nmRelation->getRefProperty(), false);
if (!empty($entityIds)) { $relEntityQuery = DatabaseEntityQuery::fetchAll($otherHandler)
$relEntityQuery = DatabaseEntityQuery::fetchAll($otherHandler) ->where(new CondIn(new Column($thisIdColumn), $entityIds));
->where(new CondIn(new Column($thisIdColumn), $entityIds));
$relEntityQuery->fetchEntities($fetchEntities === DatabaseEntityQuery::FETCH_RECURSIVE); $relEntityQuery->fetchEntities($fetchEntities === DatabaseEntityQuery::FETCH_RECURSIVE);
$rows = $relEntityQuery->executeSQL(); $rows = $relEntityQuery->executeSQL();
if (!is_array($rows)) { if (!is_array($rows)) {
$this->logger->error("Error fetching n:m relations from table: '$nmTable': " . $this->sql->getLastError()); $this->logger->error("Error fetching n:m relations from table: '$nmTable': " . $this->sql->getLastError());
return; return;
} }
$thisIdProperty = $otherHandler->properties[$nmRelation->getThisProperty()]; $thisIdProperty = $otherHandler->properties[$nmRelation->getThisProperty()];
$thisIdProperty->setAccessible(true); $thisIdProperty->setAccessible(true);
foreach ($rows as $row) { foreach ($rows as $row) {
$relEntity = $otherHandler->entityFromRow($row, [], $fetchEntities); $relEntity = $otherHandler->entityFromRow($row, [], $fetchEntities);
$thisEntity = $entities[$row[$thisIdColumn]]; $thisEntity = $entities[$row[$thisIdColumn]];
$thisIdProperty->setValue($relEntity, $thisEntity); $thisIdProperty->setValue($relEntity, $thisEntity);
$targetArray = $property->getValue($thisEntity); $targetArray = $property->getValue($thisEntity);
$targetArray[$row[$relIdColumn]] = $relEntity; $targetArray[$row[$relIdColumn]] = $relEntity;
$property->setValue($thisEntity, $targetArray); $property->setValue($thisEntity, $targetArray);
}
} }
} else { } else {
$this->logger->error("fetchNMRelations for type '" . get_class($nmRelation) . "' is not implemented"); $this->logger->error("fetchNMRelations for type '" . get_class($nmRelation) . "' is not implemented");

@ -94,7 +94,7 @@ class DatabaseEntityQuery extends Select {
$relIndex = 1; $relIndex = 1;
foreach ($this->handler->getRelations() as $propertyName => $relationHandler) { foreach ($this->handler->getRelations() as $propertyName => $relationHandler) {
if ($this->handler !== $relationHandler) { if ($this->handler !== $relationHandler || !$recursive) {
$this->fetchRelation($propertyName, $this->handler->getTableName(), $this->handler, $relationHandler, $relIndex, $recursive); $this->fetchRelation($propertyName, $this->handler->getTableName(), $this->handler, $relationHandler, $relIndex, $recursive);
} }
} }

@ -36,6 +36,13 @@ class NMRelationReference implements Persistable {
return $this->refProperty; return $this->refProperty;
} }
public function getRefColumns(): array {
return [
$this->handler->getColumnName($this->getThisProperty(), false),
$this->handler->getColumnName($this->getRefProperty(), false),
];
}
public function getRelHandler(): DatabaseEntityHandler { public function getRelHandler(): DatabaseEntityHandler {
return $this->handler; return $this->handler;
} }

@ -5,6 +5,8 @@ namespace Core\Objects\DatabaseEntity;
use Core\Driver\SQL\SQL; use Core\Driver\SQL\SQL;
use Core\Objects\DatabaseEntity\Attribute\ExtendingEnum; use Core\Objects\DatabaseEntity\Attribute\ExtendingEnum;
use Core\Objects\DatabaseEntity\Attribute\MaxLength; use Core\Objects\DatabaseEntity\Attribute\MaxLength;
use Core\Objects\DatabaseEntity\Attribute\Transient;
use Core\Objects\DatabaseEntity\Attribute\Visibility;
use Core\Objects\TwoFactor\KeyBasedTwoFactorToken; use Core\Objects\TwoFactor\KeyBasedTwoFactorToken;
use Core\Objects\TwoFactor\TimeBasedTwoFactorToken; use Core\Objects\TwoFactor\TimeBasedTwoFactorToken;
use Core\Objects\DatabaseEntity\Controller\DatabaseEntity; use Core\Objects\DatabaseEntity\Controller\DatabaseEntity;
@ -18,8 +20,13 @@ abstract class TwoFactorToken extends DatabaseEntity {
#[ExtendingEnum(self::TWO_FACTOR_TOKEN_TYPES)] private string $type; #[ExtendingEnum(self::TWO_FACTOR_TOKEN_TYPES)] private string $type;
private bool $confirmed; private bool $confirmed;
#[Transient]
private bool $authenticated; private bool $authenticated;
#[MaxLength(512)] private ?string $data;
#[MaxLength(512)]
#[Visibility(Visibility::NONE)]
private ?string $data;
public function __construct(string $type, ?int $id = null, bool $confirmed = false) { public function __construct(string $type, ?int $id = null, bool $confirmed = false) {
parent::__construct($id); parent::__construct($id);
@ -39,6 +46,7 @@ abstract class TwoFactorToken extends DatabaseEntity {
public function postFetch(SQL $sql, array $row) { public function postFetch(SQL $sql, array $row) {
parent::postFetch($sql, $row); parent::postFetch($sql, $row);
$this->authenticated = $_SESSION["2faAuthenticated"] ?? false;
$this->readData($row["data"]); $this->readData($row["data"]);
} }
@ -63,4 +71,14 @@ abstract class TwoFactorToken extends DatabaseEntity {
$this->confirmed = true; $this->confirmed = true;
return $this->save($sql) !== false; return $this->save($sql) !== false;
} }
public function jsonSerialize(?array $propertyNames = null): array {
$jsonData = parent::jsonSerialize($propertyNames);
if ($propertyNames === null || in_array("authenticated", $propertyNames)) {
$jsonData["authenticated"] = $this->authenticated;
}
return $jsonData;
}
} }

@ -2,7 +2,11 @@
namespace Core\Objects\DatabaseEntity; namespace Core\Objects\DatabaseEntity;
use Core\Driver\SQL\Column\Column;
use Core\Driver\SQL\Expression\Alias;
use Core\Driver\SQL\Expression\Coalesce;
use Core\Driver\SQL\Expression\CurrentTimeStamp; use Core\Driver\SQL\Expression\CurrentTimeStamp;
use Core\Driver\SQL\Expression\NullIf;
use Core\Driver\SQL\SQL; use Core\Driver\SQL\SQL;
use Core\Objects\DatabaseEntity\Attribute\DefaultValue; use Core\Objects\DatabaseEntity\Attribute\DefaultValue;
use Core\Objects\DatabaseEntity\Attribute\MaxLength; use Core\Objects\DatabaseEntity\Attribute\MaxLength;
@ -141,4 +145,13 @@ class User extends DatabaseEntity {
public function getDisplayName(): string { public function getDisplayName(): string {
return !empty($this->fullName) ? $this->fullName : $this->name; return !empty($this->fullName) ? $this->fullName : $this->name;
} }
public static function buildSQLDisplayName(SQL $sql, string $joinColumn): Alias {
return new Alias(
$sql->select(new Coalesce(
new NullIf(new Column("User.full_name"), ""),
new NullIf(new Column("User.name"), ""))
)->from("User")->whereEq("User.id", new Column($joinColumn)),
"user");
}
} }

@ -6,7 +6,7 @@ use Core\Objects\ApiObject;
class AttestationObject extends ApiObject { class AttestationObject extends ApiObject {
use Core\Objects\TwoFactor\CBORDecoder; use CBORDecoder;
private string $format; private string $format;
private array $statement; private array $statement;

@ -3,70 +3,92 @@
namespace Core\Objects\TwoFactor; namespace Core\Objects\TwoFactor;
use Core\Driver\SQL\SQL; use Core\Driver\SQL\SQL;
use Core\Objects\DatabaseEntity\Attribute\Transient;
use Cose\Algorithm\Signature\ECDSA\ECSignature; use Cose\Algorithm\Signature\ECDSA\ECSignature;
use Core\Objects\DatabaseEntity\TwoFactorToken; use Core\Objects\DatabaseEntity\TwoFactorToken;
use Cose\Key\Key;
class KeyBasedTwoFactorToken extends TwoFactorToken { class KeyBasedTwoFactorToken extends TwoFactorToken {
const TYPE = "fido"; const TYPE = "fido";
#[Transient]
private ?string $challenge; private ?string $challenge;
private ?string $credentialId;
#[Transient]
private ?string $credentialID;
#[Transient]
private ?PublicKey $publicKey; private ?PublicKey $publicKey;
public function __construct(string $challenge) { private function __construct() {
parent::__construct(self::TYPE); parent::__construct(self::TYPE);
$this->challenge = $challenge; }
public function generateChallenge(int $length = 32) {
$this->challenge = base64_encode(generateRandomString($length, "raw"));
$_SESSION["challenge"] = $this->challenge;
}
public static function create(int $challengeLength = 32): KeyBasedTwoFactorToken {
$token = new KeyBasedTwoFactorToken();
$token->generateChallenge($challengeLength);
return $token;
}
public function getChallenge(): string {
return $this->challenge;
} }
protected function readData(string $data) { protected function readData(string $data) {
if (!$this->isConfirmed()) { if (!$this->isConfirmed()) {
$this->challenge = base64_decode($data); $this->challenge = $data;
$this->credentialId = null; $this->credentialID = null;
$this->publicKey = null; $this->publicKey = null;
} else { } else {
$jsonData = json_decode($data, true); $jsonData = json_decode($data, true);
$this->challenge = base64_decode($_SESSION["challenge"] ?? ""); $this->challenge = $_SESSION["challenge"] ?? "";
$this->credentialId = base64_decode($jsonData["credentialID"]); $this->credentialID = base64_decode($jsonData["credentialID"]);
$this->publicKey = PublicKey::fromJson($jsonData["publicKey"]); $this->publicKey = PublicKey::fromJson($jsonData["publicKey"]);
} }
} }
public function getData(): string { public function getData(): string {
if ($this->isConfirmed()) { if (!$this->isConfirmed()) {
return base64_encode($this->challenge); return $this->challenge;
} else { } else {
return json_encode([ return json_encode([
"credentialId" => $this->credentialId, "credentialID" => $this->credentialID,
"publicKey" => $this->publicKey->jsonSerialize() "publicKey" => $this->publicKey->jsonSerialize()
]); ]);
} }
} }
public function confirmKeyBased(SQL $sql, string $credentialId, PublicKey $publicKey): bool { public function confirmKeyBased(SQL $sql, string $credentialID, PublicKey $publicKey): bool {
$this->credentialId = $credentialId; $this->credentialID = $credentialID;
$this->publicKey = $publicKey; $this->publicKey = $publicKey;
return parent::confirm($sql); return parent::confirm($sql);
} }
public function getPublicKey(): ?PublicKey { public function getPublicKey(): ?PublicKey {
return $this->publicKey; return $this->publicKey;
} }
public function getCredentialId(): ?string { public function getCredentialId(): ?string {
return $this->credentialId; return $this->credentialID;
} }
public function jsonSerialize(?array $propertyNames = null): array { public function jsonSerialize(?array $propertyNames = null): array {
$jsonData = parent::jsonSerialize(); $jsonData = parent::jsonSerialize();
if (!empty($this->challenge) && !$this->isAuthenticated() && in_array("challenge", $propertyNames)) { if (!$this->isAuthenticated()) {
$jsonData["challenge"] = base64_encode($this->challenge); if (!empty($this->challenge) && ($propertyNames === null || in_array("challenge", $propertyNames))) {
} $jsonData["challenge"] = $this->challenge;
}
if (!empty($this->credentialId) && in_array("credentialID", $propertyNames)) { if (!empty($this->credentialID) && ($propertyNames === null || in_array("credentialID", $propertyNames))) {
$jsonData["credentialID"] = base64_encode($this->credentialId); $jsonData["credentialID"] = base64_encode($this->credentialID);
}
} }
return $jsonData; return $jsonData;

@ -6,7 +6,7 @@ use Core\Objects\ApiObject;
class PublicKey extends ApiObject { class PublicKey extends ApiObject {
use Core\Objects\TwoFactor\CBORDecoder; use CBORDecoder;
private int $keyType; private int $keyType;
private int $usedAlgorithm; private int $usedAlgorithm;

@ -7,11 +7,14 @@ use chillerlan\QRCode\QRCode;
use chillerlan\QRCode\QROptions; use chillerlan\QRCode\QROptions;
use Core\Driver\SQL\SQL; use Core\Driver\SQL\SQL;
use Core\Objects\Context; use Core\Objects\Context;
use Core\Objects\DatabaseEntity\Attribute\Visibility;
use Core\Objects\DatabaseEntity\TwoFactorToken; use Core\Objects\DatabaseEntity\TwoFactorToken;
class TimeBasedTwoFactorToken extends TwoFactorToken { class TimeBasedTwoFactorToken extends TwoFactorToken {
const TYPE = "totp"; const TYPE = "totp";
#[Visibility(Visibility::NONE)]
private string $secret; private string $secret;
public function __construct(string $secret) { public function __construct(string $secret) {

@ -229,7 +229,7 @@ function getClassPath($class, string $suffix = ".class"): string {
$group = $pathParts[$pathCount - 2]; $group = $pathParts[$pathCount - 2];
if (strcasecmp($group, "Parameter") !== 0 && strcasecmp($group, "Traits") !== 0) { if (strcasecmp($group, "Parameter") !== 0 && strcasecmp($group, "Traits") !== 0) {
$pathParts = array_slice($pathParts, 0, $pathCount - 2); $pathParts = array_slice($pathParts, 0, $pathCount - 2);
$pathParts[] = "${group}API"; $pathParts[] = "{$group}API";
} }
} }
} }

34
cli.php

@ -40,9 +40,16 @@ if (!$context->isCLI()) {
} }
$database = $context->getConfig()->getDatabase(); $database = $context->getConfig()->getDatabase();
if ($database !== null && $database->getProperty("isDocker", false) && !is_file("/.dockerenv")) { if ($database->getProperty("isDocker", false) && !is_file("/.dockerenv")) {
if (count($argv) < 3 || $argv[1] !== "db" || !in_array($argv[2], ["shell", "import", "export"])) { if (function_exists("yaml_parse")) {
$dockerYaml = yaml_parse(file_get_contents("./docker-compose.yml")); $dockerYaml = yaml_parse(file_get_contents("./docker-compose.yml"));
} else {
_exit("yaml_parse not found but required for docker file parsing.");
}
}
if ($database->getProperty("isDocker", false) && !is_file("/.dockerenv")) {
if (count($argv) < 3 || $argv[1] !== "db" || !in_array($argv[2], ["shell", "import", "export"])) {
$containerName = $dockerYaml["services"]["php"]["container_name"]; $containerName = $dockerYaml["services"]["php"]["container_name"];
$command = array_merge(["docker", "exec", "-it", $containerName, "php"], $argv); $command = array_merge(["docker", "exec", "-it", $containerName, "php"], $argv);
$proc = proc_open($command, [1 => STDOUT, 2 => STDERR], $pipes, "/application"); $proc = proc_open($command, [1 => STDOUT, 2 => STDERR], $pipes, "/application");
@ -93,6 +100,7 @@ function applyPatch(\Core\Driver\SQL\SQL $sql, string $patchName): bool {
} }
function handleDatabase(array $argv) { function handleDatabase(array $argv) {
global $dockerYaml;
$action = $argv[2] ?? ""; $action = $argv[2] ?? "";
if ($action === "migrate") { if ($action === "migrate") {
@ -174,7 +182,6 @@ function handleDatabase(array $argv) {
$command = array_merge([$command_bin], $command_args); $command = array_merge([$command_bin], $command_args);
if ($config->getProperty("isDocker", false)) { if ($config->getProperty("isDocker", false)) {
$dockerYaml = yaml_parse(file_get_contents("./docker-compose.yml"));
$containerName = $dockerYaml["services"]["db"]["container_name"]; $containerName = $dockerYaml["services"]["db"]["container_name"];
$command = array_merge(["docker", "exec", "-it", $containerName], $command); $command = array_merge(["docker", "exec", "-it", $containerName], $command);
} }
@ -454,21 +461,24 @@ function onRoutes(array $argv) {
$action = $argv[2] ?? "list"; $action = $argv[2] ?? "list";
if ($action === "list") { if ($action === "list") {
$req = new \Core\API\Routes\Fetch($context); $sql = $context->getSQL();
$success = $req->execute(); $routes = \Core\Objects\DatabaseEntity\Route::findAll($sql);
if (!$success) { if ($routes === false || $routes === null) {
_exit("Error fetching routes: " . $req->getLastError()); _exit("Error fetching routes: " . $sql->getLastError());
} else { } else {
$routes = $req->getResult()["routes"];
$head = ["id", "pattern", "type", "target", "extra", "active", "exact"]; $head = ["id", "pattern", "type", "target", "extra", "active", "exact"];
// strict boolean // strict boolean
foreach ($routes as &$route) { $tableRows = [];
$route["active"] = $route["active"] ? "true" : "false"; foreach ($routes as $route) {
$route["exact"] = $route["exact"] ? "true" : "false"; $jsonData = $route->jsonSerialize(["id", "pattern", "type", "target", "extra", "active", "exact"]);
// strict bool conversion
$jsonData["active"] = $jsonData["active"] ? "true" : "false";
$jsonData["exact"] = $jsonData["exact"] ? "true" : "false";
$tableRows[] = $jsonData;
} }
printTable($head, $routes); printTable($head, $tableRows);
} }
} else if ($action === "add") { } else if ($action === "add") {
if (count($argv) < 7) { if (count($argv) < 7) {

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="237pt" viewBox="0 0 1024 1024" width="237pt" xmlns="http://www.w3.org/2000/svg">
<path d="m457.07 45.92c73.67-8.62 149.41.22 218.93 26.24 62.75 23.47 120.59 60.41 167.72 108.09-40.31 39.97-80.25 80.31-120.51 120.33-25.95-25.45-56.19-46.65-89.47-61.37-44.22-19.82-93.39-28.54-141.72-25.2-54.6 3.52-108.11 22.49-152.63 54.3-55.24 38.95-96.61 97.22-114.8 162.35-13.64 47.78-14.77 98.97-3.76 147.4 8.85 39.61 26.09 77.29 50.05 110.03 22.24 30.45 50.3 56.64 82.22 76.71 36.79 23.24 78.76 38.22 121.96 43.48 37.02 4.65 74.9 2.23 111.05-7 3.97-1.91 6.24 2.61 8.73 4.86 40.74 40.95 81.68 81.7 122.43 122.63-73.31 35.6-155.99 51.86-237.3 46.12-68.05-4.51-135.07-24.08-194.71-57.18-24.87-13.6-48.23-29.82-70.33-47.54-17.46-14.81-34.71-30.01-49.72-47.35-25.41-27.39-47.21-58.11-65.17-90.87-4-7.72-8.64-15.12-11.9-23.2-4.47-9.05-8.37-18.36-12.25-27.67-4.96-13.27-10.26-26.44-13.88-40.15-.43-1.81-1.53-3.33-2.52-4.85-8.3-28.66-12.95-58.33-15.44-88.04-2.62-37.36-1.33-75.05 4.78-112.03 12.18-75.48 43.41-147.74 89.86-208.44 30.12-39.5 66.53-74.21 107.46-102.35 62.44-43.11 135.53-70.69 210.92-79.3m49.18 50.48c-5.7.99-10.96 3.76-15.38 7.43-10.4 8.93-14.14 24.81-8.17 37.28 6.33 15.02 25.16 23.1 40.36 17 15.43-5.24 24.78-23.51 19.74-39.06-4.1-15.43-20.92-25.79-36.55-22.65m-267.96 111.99c-5.33.28-10.59 1.92-15.16 4.68-11.47 7.03-17.88 21.74-14.36 34.86 3.09 14.08 16.78 25.06 31.26 24.61 15.03.41 29.08-11.39 31.54-26.17 2.24-10.33-1.61-21.55-9.14-28.85-6.24-6.33-15.3-9.62-24.14-9.13m-115.96 272c-6.62 1.23-12.84 4.58-17.54 9.4-9.45 9.72-11.72 25.57-5.02 37.42 7.16 13.96 25.48 20.86 39.98 14.62 15.02-5.51 23.99-23.42 19.06-38.71-4.04-15.41-20.86-25.96-36.48-22.73m111.98 272.05c-11.49 1.95-21.48 10.72-24.84 21.88-3.87 11.63-.13 25.16 8.9 33.36 6.15 5.48 14.4 8.7 22.67 8.32 11.16-.25 21.98-6.87 27.12-16.8 4.93-8.83 5.15-20.02.71-29.1-5.82-12.78-20.81-20.33-34.56-17.66m272.06 112.03c-11.76 2.02-21.92 11.07-25.09 22.6-3.87 12.57 1.13 27.25 11.91 34.79 7.86 5.79 18.42 7.67 27.77 4.86 9.61-2.77 17.7-10.24 21.07-19.68 4.97-12.74.36-28.32-10.58-36.46-7.02-5.45-16.35-7.67-25.08-6.11z" fill="#a0a0a0"/>
<g fill="#c1c1c1">
<path d="m506.25 96.4c15.63-3.14 32.45 7.22 36.55 22.65 5.04 15.55-4.31 33.82-19.74 39.06-15.2 6.1-34.03-1.98-40.36-17-5.97-12.47-2.23-28.35 8.17-37.28 4.42-3.67 9.68-6.44 15.38-7.43z"/>
<path d="m122.33 480.39c15.62-3.23 32.44 7.32 36.48 22.73 4.93 15.29-4.04 33.2-19.06 38.71-14.5 6.24-32.82-.66-39.98-14.62-6.7-11.85-4.43-27.7 5.02-37.42 4.7-4.82 10.92-8.17 17.54-9.4z"/>
</g>
<path d="m843.72 180.25.11-.11c24.64 25.12 46.96 52.6 65.61 82.47 36.01 57.16 59.38 122.22 67.87 189.25 5.67 44.88 5.27 90.6-2.05 135.27-12.7 80.01-46.94 156.41-97.9 219.36-42.3 52.48-95.87 96.04-156.41 125.76l-.63-.26c-1.01-1.1-2.03-2.16-3.05-3.22-40.75-40.93-81.69-81.68-122.43-122.63-2.49-2.25-4.76-6.77-8.73-4.86-36.15 9.23-74.03 11.65-111.05 7-43.2-5.26-85.17-20.24-121.96-43.48-31.92-20.07-59.98-46.26-82.22-76.71-23.96-32.74-41.2-70.42-50.05-110.03-11.01-48.43-9.88-99.62 3.76-147.4 18.19-65.13 59.56-123.4 114.8-162.35 44.52-31.81 98.03-50.78 152.63-54.3 48.33-3.34 97.5 5.38 141.72 25.2 33.28 14.72 63.52 35.92 89.47 61.37 40.26-40.02 80.2-80.36 120.51-120.33m-351.75 183.97c-21.23 2.85-41.89 10.28-59.88 21.94-23.08 14.45-41.76 35.58-53.9 59.91-14.58 29.2-18.64 63.21-12.5 95.17 3.93 19.85 12.03 38.88 23.56 55.51 7.93 11.52 17.65 21.66 27.65 31.38 57.33 57.41 114.61 114.86 171.99 172.21 49.09-13.07 94.67-38.94 131.05-74.4 45.11-43.34 75.66-101.44 86.31-163.06.24-1.04.22-2.91 1.85-2.6 29.63.04 59.26-.01 88.9.05 19.04-.3 36.87-14.77 40.76-33.46.22-1.21 0-2.32-.67-3.35 1.22-5.71 2.08-11.58 1.38-17.43l-.24.4c-2.05-20.34-20.84-37.12-41.25-37.17-80.64-.07-161.28-.01-241.93-.07-6.44-22.08-18.5-42.33-34.03-59.23-25.8-27.5-62.26-45.01-100.02-46.82-9.68.02-19.44-.54-29.03 1.02m286.37 388.31c-12.64 2.06-23.37 12.59-25.7 25.18-1.48 8.14-.14 17.02 4.73 23.85 5.76 9.2 16.8 14.76 27.61 14.25 8.32.03 16.28-3.95 22.16-9.66 4.87-5.6 8.45-12.67 8.67-20.2.86-11.37-5.06-23.1-14.87-28.96-6.52-4.46-14.89-5.75-22.6-4.46z" fill="#686868"/>
<path d="m238.29 208.39c8.84-.49 17.9 2.8 24.14 9.13 7.53 7.3 11.38 18.52 9.14 28.85-2.46 14.78-16.51 26.58-31.54 26.17-14.48.45-28.17-10.53-31.26-24.61-3.52-13.12 2.89-27.83 14.36-34.86 4.57-2.76 9.83-4.4 15.16-4.68z" fill="#c3c3c3"/>
<path d="m491.97 364.22c9.59-1.56 19.35-1 29.03-1.02 37.76 1.81 74.22 19.32 100.02 46.82 15.53 16.9 27.59 37.15 34.03 59.23-47.67-.01-95.35-.01-143.03-.01-12.19-.11-24.23 5.42-32.21 14.61-6.01 6.85-9.74 15.65-10.44 24.74-1.19 14.17 5.36 28.7 16.69 37.28 7.62 5.91 17.28 9 26.92 8.8 126.69-.04 253.39-.06 380.08.03 4.76.04 9.59-.15 14.2-1.48 14.21-3.82 26.02-15.47 29.83-29.7.67 1.03.89 2.14.67 3.35-3.89 18.69-21.72 33.16-40.76 33.46-29.64-.06-59.27-.01-88.9-.05-1.63-.31-1.61 1.56-1.85 2.6-10.65 61.62-41.2 119.72-86.31 163.06-36.38 35.46-81.96 61.33-131.05 74.4-57.38-57.35-114.66-114.8-171.99-172.21-10-9.72-19.72-19.86-27.65-31.38-11.53-16.63-19.63-35.66-23.56-55.51-6.14-31.96-2.08-65.97 12.5-95.17 12.14-24.33 30.82-45.46 53.9-59.91 17.99-11.66 38.65-19.09 59.88-21.94z" fill="#474747"/>
<g fill="#bfbfbf">
<path d="m479.81 483.85c7.98-9.19 20.02-14.72 32.21-14.61 47.68 0 95.36 0 143.03.01 80.65.06 161.29 0 241.93.07 20.41.05 39.2 16.83 41.25 37.17-.19.33-.58.97-.78 1.29-3.68-18.51-21.57-33.16-40.47-33.05-124.97.1-249.94.12-374.91.17-7.73.13-15.74-.8-23.15 1.96-9.06 2.84-16.73 9.1-22.27 16.7-3.34 4.55-4.39 10.24-7.28 15.03.7-9.09 4.43-17.89 10.44-24.74z"/>
<path d="m234.31 752.44c13.75-2.67 28.74 4.88 34.56 17.66 4.44 9.08 4.22 20.27-.71 29.1-5.14 9.93-15.96 16.55-27.12 16.8-8.27.38-16.52-2.84-22.67-8.32-9.03-8.2-12.77-21.73-8.9-33.36 3.36-11.16 13.35-19.93 24.84-21.88z"/>
</g>
<path d="m498.92 476.86c7.41-2.76 15.42-1.83 23.15-1.96 124.97-.05 249.94-.07 374.91-.17 18.9-.11 36.79 14.54 40.47 33.05.2-.32.59-.96.78-1.29l.24-.4c.7 5.85-.16 11.72-1.38 17.43-3.81 14.23-15.62 25.88-29.83 29.7-4.61 1.33-9.44 1.52-14.2 1.48-126.69-.09-253.39-.07-380.08-.03-9.64.2-19.3-2.89-26.92-8.8-11.33-8.58-17.88-23.11-16.69-37.28 2.89-4.79 3.94-10.48 7.28-15.03 5.54-7.6 13.21-13.86 22.27-16.7z" fill="#b0b0b0"/>
<path d="m59.49 636.08c.99 1.52 2.09 3.04 2.52 4.85 3.62 13.71 8.92 26.88 13.88 40.15 3.88 9.31 7.78 18.62 12.25 27.67 3.26 8.08 7.9 15.48 11.9 23.2 17.96 32.76 39.76 63.48 65.17 90.87 15.01 17.34 32.26 32.54 49.72 47.35 22.1 17.72 45.46 33.94 70.33 47.54 59.64 33.1 126.66 52.67 194.71 57.18 81.31 5.74 163.99-10.52 237.3-46.12 1.02 1.06 2.04 2.12 3.05 3.22l.03.61c-42.32 21.03-87.88 35.53-134.56 42.91-56.91 8.9-115.46 7.49-171.82-4.5-67.89-14.35-132.42-44.21-187.42-86.51-63.52-48.63-114.32-113.73-145.86-187.27-8.42-19.87-15.93-40.22-21.2-61.15z" fill="#939393"/>
<path d="m778.34 752.53c7.71-1.29 16.08 0 22.6 4.46 9.81 5.86 15.73 17.59 14.87 28.96-.22 7.53-3.8 14.6-8.67 20.2-5.88 5.71-13.84 9.69-22.16 9.66-10.81.51-21.85-5.05-27.61-14.25-4.87-6.83-6.21-15.71-4.73-23.85 2.33-12.59 13.06-23.12 25.7-25.18z" fill="#767676"/>
<path d="m506.37 864.47c8.73-1.56 18.06.66 25.08 6.11 10.94 8.14 15.55 23.72 10.58 36.46-3.37 9.44-11.46 16.91-21.07 19.68-9.35 2.81-19.91.93-27.77-4.86-10.78-7.54-15.78-22.22-11.91-34.79 3.17-11.53 13.33-20.58 25.09-22.6z" fill="#bebebe"/>
</svg>

After

Width:  |  Height:  |  Size: 7.1 KiB

BIN
img/icons/nitrokey.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

@ -159,7 +159,7 @@ let Core = function () {
let jsCore = new Core(); let jsCore = new Core();
function L(key) { function L(key, defaultTranslation=null) {
let entries = window.languageEntries || {}; let entries = window.languageEntries || {};
let [module, variable] = key.split("."); let [module, variable] = key.split(".");
@ -170,5 +170,5 @@ function L(key) {
} }
} }
return "[" + key + "]"; return defaultTranslation || "[" + key + "]";
} }

@ -6,6 +6,7 @@ export default class API {
this.loggedIn = false; this.loggedIn = false;
this.user = null; this.user = null;
this.session = null; this.session = null;
this.language = { id: 1, code: "en_US", shortCode: "en", name: "American English" };
this.permissions = []; this.permissions = [];
} }
@ -80,24 +81,31 @@ export default class API {
/** UserAPI **/ /** UserAPI **/
async login(username, password, rememberMe=false) { async login(username, password, rememberMe=false) {
return this.apiCall("user/login", { username: username, password: password, stayLoggedIn: rememberMe }) let res = await this.apiCall("user/login", { username: username, password: password, stayLoggedIn: rememberMe });
if (res.success) {
this.loggedIn = true;
this.session = res.session;
this.user = res.user;
}
return res;
} }
async fetchUser() { async fetchUser() {
let response = await fetch("/api/user/info"); let res = await this.apiCall("user/info");
let data = await response.json(); if (res.success) {
if (data) { this.loggedIn = res.loggedIn;
this.loggedIn = data["loggedIn"]; this.language = res.language;
this.permissions = data["permissions"] ? data["permissions"].map(s => s.toLowerCase()) : []; this.permissions = (res.permissions || []).map(s => s.toLowerCase());
if (this.loggedIn) { if (this.loggedIn) {
this.session = data["session"]; this.session = res.session;
this.user = data["user"]; this.user = res.user;
} else { } else {
this.session = null; this.session = null;
this.user = null; this.user = null;
} }
} }
return data; return res;
} }
async editUser(id, username, email, password, groups, confirmed) { async editUser(id, username, email, password, groups, confirmed) {
@ -147,6 +155,11 @@ export default class API {
return this.apiCall("user/create", { username: username, email: email, password: password, confirmPassword: confirmPassword }); return this.apiCall("user/create", { username: username, email: email, password: password, confirmPassword: confirmPassword });
} }
async updateProfile(username=null, fullName=null, password=null, confirmPassword = null, oldPassword = null) {
return this.apiCall("user/updateProfile", { username: username, fullName: fullName,
password: password, confirmPassword: confirmPassword, oldPassword: oldPassword });
}
/** Stats **/ /** Stats **/
async getStats() { async getStats() {
return this.apiCall("stats"); return this.apiCall("stats");
@ -204,7 +217,12 @@ export default class API {
} }
async setLanguage(params) { async setLanguage(params) {
return await this.apiCall("language/set", params); let res = await this.apiCall("language/set", params);
if (res.success) {
this.language = res.language;
}
return res;
} }
async getLanguageEntries(modules, code=null, useCache=false) { async getLanguageEntries(modules, code=null, useCache=false) {
@ -216,7 +234,6 @@ export default class API {
} }
/** ApiKeyAPI **/ /** ApiKeyAPI **/
// API-Key API
async getApiKeys(showActiveOnly = false) { async getApiKeys(showActiveOnly = false) {
return this.apiCall("apiKey/fetch", { showActiveOnly: showActiveOnly }); return this.apiCall("apiKey/fetch", { showActiveOnly: showActiveOnly });
} }
@ -228,4 +245,42 @@ export default class API {
async revokeKey(id) { async revokeKey(id) {
return this.apiCall("apiKey/revoke", { id: id }); return this.apiCall("apiKey/revoke", { id: id });
} }
/** 2FA API **/
async confirmTOTP(code) {
return this.apiCall("tfa/confirmTotp", { code: code });
}
async remove2FA(password) {
return this.apiCall("tfa/remove", { password: password });
}
async verifyTotp2FA(code) {
return this.apiCall("tfa/verifyTotp", { code: code });
}
async verifyKey2FA(credentialID, clientDataJSON, authData, signature) {
return this.apiCall("tfa/verifyKey", { credentialID: credentialID, clientDataJSON: clientDataJSON, authData: authData, signature: signature })
}
async register2FA(clientDataJSON = null, attestationObject = null) {
return this.apiCall("tfa/registerKey", { clientDataJSON: clientDataJSON, attestationObject: attestationObject });
}
/** GPG API **/
async uploadGPG(pubkey) {
return this.apiCall("user/importGPG", { pubkey: pubkey });
}
async confirmGpgToken(token) {
return this.apiCall("user/confirmGPG", { token: token });
}
async removeGPG(password) {
return this.apiCall("user/removeGPG", { password: password });
}
async downloadGPG(userId) {
return this.apiCall("user/downloadGPG", { id: userId }, true);
}
}; };

@ -3,7 +3,7 @@
} }
.data-table td, .data-table th { .data-table td, .data-table th {
padding: 2px; padding: 5px;
vertical-align: middle; vertical-align: middle;
} }
@ -11,10 +11,17 @@
background-color: #bbb; background-color: #bbb;
} }
.sortable { .data-table th > svg {
cursor: pointer; vertical-align: middle;
width: 18px;
height: 18px;
margin-right: 5px;
} }
.center { .center {
text-align: center; text-align: center;
} }
.data-table-clickable {
cursor: pointer;
}

@ -2,48 +2,51 @@ import {Table, TableBody, TableCell, TableHead, TableRow} from "@material-ui/cor
import ArrowUpwardIcon from "@material-ui/icons/ArrowUpward"; import ArrowUpwardIcon from "@material-ui/icons/ArrowUpward";
import ArrowDownwardIcon from "@material-ui/icons/ArrowDownward"; import ArrowDownwardIcon from "@material-ui/icons/ArrowDownward";
import React, {useCallback, useContext, useEffect, useState} from "react"; import React, {useCallback, useContext, useEffect, useState} from "react";
import usePagination from "../hooks/pagination";
import {parse} from "date-fns";
import "./data-table.css"; import "./data-table.css";
import {LocaleContext} from "../locale"; import {LocaleContext} from "../locale";
import clsx from "clsx"; import clsx from "clsx";
import {Box} from "@mui/material"; import {Box, IconButton} from "@mui/material";
import {formatDate} from "../util"; import {formatDateTime} from "../util";
import UserLink from "security-lab/src/elements/user/userlink";
import CachedIcon from "@material-ui/icons/Cached";
export function DataTable(props) { export function DataTable(props) {
const { className, placeholder, const { className, placeholder,
columns, data, pagination,
fetchData, onClick, onFilter, fetchData, onClick, onFilter,
defaultSortColumn, defaultSortOrder, defaultSortColumn, defaultSortOrder,
columns, ...other } = props; title, ...other } = props;
const {currentLocale, requestModules, translate: L} = useContext(LocaleContext); const {translate: L} = useContext(LocaleContext);
const [doFetchData, setFetchData] = useState(false); const [doFetchData, setFetchData] = useState(false);
const [data, setData] = useState(null);
const [sortAscending, setSortAscending] = useState(["asc","ascending"].includes(defaultSortOrder?.toLowerCase)); const [sortAscending, setSortAscending] = useState(["asc","ascending"].includes(defaultSortOrder?.toLowerCase));
const [sortColumn, setSortColumn] = useState(defaultSortColumn || null); const [sortColumn, setSortColumn] = useState(defaultSortColumn || null);
const pagination = usePagination();
const sortable = props.hasOwnProperty("sortable") ? !!props.sortable : true; const sortable = props.hasOwnProperty("sortable") ? !!props.sortable : true;
const onRowClick = onClick || (() => {});
const onFetchData = useCallback((force = false) => { const onFetchData = useCallback((force = false) => {
if (doFetchData || force) { if (doFetchData || force) {
setFetchData(false); setFetchData(false);
const orderBy = columns[sortColumn]?.field || null; const orderBy = columns[sortColumn]?.field || null;
const sortOrder = sortAscending ? "asc" : "desc"; const sortOrder = sortAscending ? "asc" : "desc";
fetchData(pagination.getPage(), pagination.getPageSize(), orderBy, sortOrder).then(([data, dataPagination]) => { fetchData(pagination.getPage(), pagination.getPageSize(), orderBy, sortOrder);
if (data) {
setData(data);
pagination.update(dataPagination);
}
});
} }
}, [doFetchData, columns, sortColumn, sortAscending, pagination]); }, [doFetchData, columns, sortColumn, sortAscending, pagination]);
// pagination changed? // pagination changed?
useEffect(() => { useEffect(() => {
let forceFetch = (pagination.getPageSize() < pagination.getTotal()); let forceFetch = false;
if (pagination.getPageSize() < pagination.getTotal()) {
// page size is smaller than the total count
forceFetch = true;
} else if (data?.length && pagination.getPageSize() >= data.length && data.length < pagination.getTotal()) {
// page size is greater than the current visible count but there were hidden rows before
forceFetch = true;
}
onFetchData(forceFetch); onFetchData(forceFetch);
}, [pagination.data.pageSize, pagination.data.current]); }, [pagination.data.pageSize, pagination.data.current]);
@ -69,13 +72,14 @@ export function DataTable(props) {
} }
if (sortable && column.sortable) { if (sortable && column.sortable) {
headerRow.push(<TableCell key={"col-" + index} className={"sortable"} headerRow.push(<TableCell key={"col-" + index} className={"data-table-clickable"}
title={L("general.sort_by") + ": " + column.label} title={L("general.sort_by") + ": " + column.label}
onClick={() => onChangeSort(index, column) }> onClick={() => onChangeSort(index, column)}
align={column.align}>
{sortColumn === index ? (sortAscending ? <ArrowUpwardIcon /> : <ArrowDownwardIcon />): <></>}{column.renderHead(index)} {sortColumn === index ? (sortAscending ? <ArrowUpwardIcon /> : <ArrowDownwardIcon />): <></>}{column.renderHead(index)}
</TableCell>); </TableCell>);
} else { } else {
headerRow.push(<TableCell key={"col-" + index}> headerRow.push(<TableCell key={"col-" + index} align={column.align}>
{column.renderHead(index)} {column.renderHead(index)}
</TableCell>); </TableCell>);
} }
@ -83,14 +87,20 @@ export function DataTable(props) {
const numColumns = columns.length; const numColumns = columns.length;
let rows = []; let rows = [];
if (data) { if (data && data?.length) {
for (const [key, entry] of Object.entries(data)) { for (const [rowIndex, entry] of data.entries()) {
let row = []; let row = [];
for (const [index, column] of columns.entries()) { for (const [index, column] of columns.entries()) {
row.push(<TableCell key={"col-" + index}>{column.renderData(L, entry)}</TableCell>); row.push(<TableCell key={"col-" + index} align={column.align}>
{column.renderData(L, entry, index)}
</TableCell>);
} }
rows.push(<TableRow key={"row-" + key}>{ row }</TableRow>); rows.push(<TableRow className={clsx({["data-table-clickable"]: typeof onClick === 'function'})}
onClick={() => onRowClick(rowIndex, entry)}
key={"row-" + rowIndex}>
{ row }
</TableRow>);
} }
} else if (placeholder) { } else if (placeholder) {
rows.push(<TableRow key={"row-placeholder"}> rows.push(<TableRow key={"row-placeholder"}>
@ -100,151 +110,13 @@ export function DataTable(props) {
</TableRow>); </TableRow>);
} }
/*
let columnElements = [];
if (columns) {
for (const [key, column] of Object.entries(columns)) {
const centered = column.alignment === "center";
const sortable = doSort && (!column.hasOwnProperty("sortable") || !!column.sortable);
const label = column.label;
if (!sortable) {
columnElements.push(
<TableCell key={"column-" + key} className={clsx(centered && classes.columnCenter)}>
{ label }
</TableCell>
);
} else {
columnElements.push(
<TableCell key={"column-" + key} label={L("Sort By") + ": " + label} className={clsx(classes.clickable, centered && classes.columnCenter)}
onClick={() => (key === sortColumn ? setSortAscending(!sortAscending) : setSortColumn(key)) }>
{ key === sortColumn ?
<Grid container alignItems={"center"} spacing={1} direction={"row"} className={classes.gridSorted}>
<Grid item>{ sortAscending ? <ArrowUpwardIcon fontSize={"small"} /> : <ArrowDownwardIcon fontSize={"small"} /> }</Grid>
<Grid item>{ label }</Grid>
<Grid item/>
</Grid> :
<span><i/>{label}</span>
}
</TableCell>
);
}
}
}
const getValue = useCallback((entry, key) => {
if (typeof columns[key]?.value === 'function') {
return columns[key].value(entry);
} else {
return entry[columns[key]?.value] ?? null;
}
}, [columns]);
let numColumns = columns ? Object.keys(columns).length : 0;
const compare = (a,b,callback) => {
let definedA = a !== null && typeof a !== 'undefined';
let definedB = b !== null && typeof b !== 'undefined';
if (!definedA && !definedB) {
return 0;
} else if (!definedA) {
return 1;
} else if (!definedB) {
return -1;
} else {
return callback(a,b);
}
}
let rows = [];
const hasClickHandler = typeof onClick === 'function';
if (data !== null && columns) {
let hidden = 0;
let sortedEntries = data.slice();
if (sortColumn && columns[sortColumn]) {
let sortFunction;
if (typeof columns[sortColumn]?.compare === 'function') {
sortFunction = columns[sortColumn].compare;
} else if (columns[sortColumn]?.type === Date) {
sortFunction = (a, b) => compare(a, b, (a,b) => a.getTime() - b.getTime());
} else if (columns[sortColumn]?.type === Number) {
sortFunction = (a, b) => compare(a, b, (a,b) => a - b);
} else {
sortFunction = ((a, b) =>
compare(a, b, (a,b) => a.toString().toLowerCase().localeCompare(b.toString().toLowerCase()))
)
}
sortedEntries.sort((a, b) => {
let entryA = getValue(a, sortColumn);
let entryB = getValue(b, sortColumn);
return sortFunction(entryA, entryB);
});
if (!sortAscending) {
sortedEntries = sortedEntries.reverse();
}
}
Array.from(Array(sortedEntries.length).keys()).forEach(rowIndex => {
if (typeof props.filter === 'function' && !props.filter(sortedEntries[rowIndex])) {
hidden++;
return;
}
let rowData = [];
for (const [key, column] of Object.entries(columns)) {
let value = getValue(sortedEntries[rowIndex], key);
if (typeof column.render === 'function') {
value = column.render(sortedEntries[rowIndex], value);
}
rowData.push(<TableCell key={"column-" + key} className={clsx(column.alignment === "center" && classes.columnCenter)}>
{ value }
</TableCell>);
}
rows.push(
<TableRow key={"entry-" + rowIndex}
className={clsx(hasClickHandler && classes.clickable)}
onClick={() => hasClickHandler && onClick(sortedEntries[rowIndex])}>
{ rowData }
</TableRow>
);
});
if (hidden > 0) {
rows.push(<TableRow key={"row-hidden"}>
<TableCell colSpan={numColumns} className={classes.hidden}>
{ "(" + (hidden > 1
? sprintf(L("%d rows hidden due to filter"), hidden)
: L("1 rows hidden due to filter")) + ")"
}
</TableCell>
</TableRow>);
} else if (rows.length === 0 && placeholder) {
rows.push(<TableRow key={"row-placeholder"}>
<TableCell colSpan={numColumns} className={classes.hidden}>
{ placeholder }
</TableCell>
</TableRow>);
}
} else if (columns && data === null) {
rows.push(<TableRow key={"loading"}>
<TableCell colSpan={numColumns} className={classes.columnCenter}>
<Grid container alignItems={"center"} spacing={1} justifyContent={"center"}>
<Grid item>{L("Loading")}</Grid>
<Grid item><CircularProgress size={15}/></Grid>
</Grid>
</TableCell>
</TableRow>)
}
*/
return <Box position={"relative"}> return <Box position={"relative"}>
<h3>
<IconButton onClick={() => onFetchData(true)}>
<CachedIcon/>
</IconButton>
{title}
</h3>
<Table className={clsx("data-table", className)} size="small" {...other}> <Table className={clsx("data-table", className)} size="small" {...other}>
<TableHead> <TableHead>
<TableRow> <TableRow>
@ -260,17 +132,14 @@ export function DataTable(props) {
} }
export class DataColumn { export class DataColumn {
constructor(label, field = null, sortable = true) { constructor(label, field = null, params = {}) {
this.label = label; this.label = label;
this.field = field; this.field = field;
this.sortable = sortable; this.sortable = !params.hasOwnProperty("sortable") || !!params.sortable;
this.align = params.align || "left";
} }
compare(a, b) { renderData(L, entry, index) {
throw new Error("Not implemented: compare");
}
renderData(L, entry) {
return entry[this.field] return entry[this.field]
} }
@ -280,49 +149,88 @@ export class DataColumn {
} }
export class StringColumn extends DataColumn { export class StringColumn extends DataColumn {
constructor(label, field = null, sortable = true, caseSensitive = false) { constructor(label, field = null, params = {}) {
super(label, field, sortable); super(label, field, params);
this.caseSensitve = caseSensitive;
}
compare(a, b) {
if (this.caseSensitve) {
return a.toString().localeCompare(b.toString());
} else {
return a.toString().toLowerCase().localeCompare(b.toString().toLowerCase());
}
} }
} }
export class NumericColumn extends DataColumn { export class NumericColumn extends DataColumn {
constructor(label, field = null, sortable = true) { constructor(label, field = null, params = {}) {
super(label, field, sortable); super(label, field, params);
this.decimalDigits = params.decimalDigits || null;
this.integerDigits = params.integerDigits || null;
this.prefix = params.prefix || "";
this.suffix = params.suffix || "";
this.decimalChar = params.decimalChar || ".";
} }
compare(a, b) { renderData(L, entry, index) {
return a - b; let number = super.renderData(L, entry).toString();
if (this.decimalDigits !== null) {
number = number.toFixed(this.decimalDigits);
}
if (this.integerDigits !== null) {
let currentLength = number.split(".")[0].length;
if (currentLength < this.integerDigits) {
number = number.padStart(this.integerDigits - currentLength, "0");
}
}
if (this.decimalChar !== ".") {
number = number.replace(".", this.decimalChar);
}
return this.prefix + number + this.suffix;
} }
} }
export class DateTimeColumn extends DataColumn { export class DateTimeColumn extends DataColumn {
constructor(label, field = null, sortable = true, format = "YYYY-MM-dd HH:mm:ss") { constructor(label, field = null, params = {}) {
super(label, field, sortable); super(label, field, params);
this.format = format; this.precise = !!params.precise;
} }
compare(a, b) { renderData(L, entry, index) {
if (typeof a === 'string') { let date = super.renderData(L, entry);
a = parse(a, this.format, new Date()).getTime(); return formatDateTime(L, date, this.precise);
} }
}
if (typeof b === 'string') {
b = parse(b, this.format, new Date()).getTime(); export class UserLinkColumn extends DataColumn {
} constructor(label, field = null, params = {}) {
super(label, field, params);
return a - b; }
}
renderData(L, entry, index) {
renderData(L, entry) { return <UserLink user={super.renderData(L, entry)}/>
return formatDate(L, super.renderData(L, entry)); }
}
export class ControlsColumn extends DataColumn {
constructor(buttons = [], params = {}) {
super("general.controls", null, { align: "center", ...params, sortable: false });
this.buttons = buttons;
}
renderData(L, entry, index) {
let buttonElements = [];
for (const [index, button] of this.buttons.entries()) {
let element = button.element;
let props = {
key: "button-" + index,
onClick: (() => button.onClick(entry)),
className: "data-table-clickable"
}
if (typeof button.showIf !== 'function' || button.showIf(entry)) {
buttonElements.push(React.createElement(element, props))
}
}
return <>
{buttonElements}
</>
} }
} }

@ -25,6 +25,10 @@ class Pagination {
this.setData({...this.data, pageSize: pageSize}); this.setData({...this.data, pageSize: pageSize});
} }
setTotal(count) {
this.setData({...this.data, total: count});
}
reset() { reset() {
this.setData({current: 1, pageSize: 25, total: 0}); this.setData({current: 1, pageSize: 25, total: 0});
} }
@ -37,6 +41,10 @@ class Pagination {
} }
} }
getParams() {
return [this.data.current, this.data.pageSize];
}
getTotal() { getTotal() {
return this.data.total; return this.data.total;
} }

@ -62,16 +62,6 @@ function LocaleProvider(props) {
} }
}, [entries]); }, [entries]);
const toDateFns = () => {
switch (currentLocale) {
case 'de_DE':
return dateFnsDE;
case 'en_US':
default:
return dateFnsEN;
}
}
/** API HOOKS **/ /** API HOOKS **/
const setLanguage = useCallback(async (api, params) => { const setLanguage = useCallback(async (api, params) => {
let res = await api.setLanguage(params); let res = await api.setLanguage(params);
@ -96,8 +86,8 @@ function LocaleProvider(props) {
if (code === null) { if (code === null) {
code = currentLocale; code = currentLocale;
if (code === null && api.loggedIn) { if (code === null && api.language) {
code = api.user.language.code; code = api.language.code;
} }
} }
@ -135,11 +125,23 @@ function LocaleProvider(props) {
} }
}, [currentLocale, getModule, dispatch]); }, [currentLocale, getModule, dispatch]);
const toDateFns = useCallback(() => {
switch (currentLocale) {
case 'de_DE':
return dateFnsDE;
case 'en_US':
default:
return dateFnsEN;
}
}, [currentLocale]);
const ctx = { const ctx = {
currentLocale: currentLocale, currentLocale: currentLocale,
translate: translate, translate: translate,
requestModules: requestModules, requestModules: requestModules,
setLanguageByCode: setLanguageByCode, setLanguageByCode: setLanguageByCode,
toDateFns: toDateFns,
setCurrentLocale: setCurrentLocale,
}; };
return ( return (

@ -59,7 +59,7 @@ const formatDate = (L, apiDate) => {
return format(apiDate, L("general.datefns_date_format", "YYY/MM/dd")); return format(apiDate, L("general.datefns_date_format", "YYY/MM/dd"));
} }
const formatDateTime = (L, apiDate) => { const formatDateTime = (L, apiDate, precise=false) => {
if (!(apiDate instanceof Date)) { if (!(apiDate instanceof Date)) {
if (!isNaN(apiDate)) { if (!isNaN(apiDate)) {
apiDate = new Date(apiDate * 1000); apiDate = new Date(apiDate * 1000);
@ -68,7 +68,10 @@ const formatDateTime = (L, apiDate) => {
} }
} }
return format(apiDate, L("general.datefns_date_time_format", "YYY/MM/dd HH:mm:ss")); let dateFormat = precise ?
L("general.datefns_date_time_format_precise", "YYY/MM/dd HH:mm:ss") :
L("general.datefns_date_time_format", "YYY/MM/dd HH:mm");
return format(apiDate, dateFormat);
} }
const upperFirstChars = (str) => { const upperFirstChars = (str) => {

@ -71,15 +71,24 @@ export default function LoginForm(props) {
const api = props.api; const api = props.api;
const classes = useStyles(); const classes = useStyles();
// inputs
let [username, setUsername] = useState(""); let [username, setUsername] = useState("");
let [password, setPassword] = useState(""); let [password, setPassword] = useState("");
let [rememberMe, setRememberMe] = useState(true); let [rememberMe, setRememberMe] = useState(true);
let [isLoggingIn, setLoggingIn] = useState(false);
let [emailConfirmed, setEmailConfirmed] = useState(null); let [emailConfirmed, setEmailConfirmed] = useState(null);
let [tfaCode, set2FACode] = useState(""); let [tfaCode, set2FACode] = useState("");
let [tfaState, set2FAState] = useState(0); // 0: not sent, 1: sent, 2: retry
let [tfaError, set2FAError] = useState(""); // 2fa
// 0: not sent, 1: sent, 2: retry
let [tfaToken, set2FAToken] = useState(api.user?.twoFactorToken || { authenticated: false, type: null, step: 0 });
let [error, setError] = useState(""); let [error, setError] = useState("");
const abortController = new AbortController();
const abortSignal = abortController.signal;
// state
let [isLoggingIn, setLoggingIn] = useState(false);
let [loaded, setLoaded] = useState(false); let [loaded, setLoaded] = useState(false);
const {translate: L, currentLocale, requestModules} = useContext(LocaleContext); const {translate: L, currentLocale, requestModules} = useContext(LocaleContext);
@ -103,13 +112,14 @@ export default function LoginForm(props) {
setLoggingIn(true); setLoggingIn(true);
removeParameter("success"); removeParameter("success");
api.login(username, password, rememberMe).then((res) => { api.login(username, password, rememberMe).then((res) => {
set2FAState(0); let twoFactorToken = res.twoFactorToken || { };
set2FAToken({ ...twoFactorToken, authenticated: false, step: 0, error: "" });
setLoggingIn(false); setLoggingIn(false);
setPassword(""); setPassword("");
if (!res.success) { if (!res.success) {
setEmailConfirmed(res.emailConfirmed); setEmailConfirmed(res.emailConfirmed);
setError(res.msg); setError(res.msg);
} else { } else if (!twoFactorToken.type) {
props.onLogin(); props.onLogin();
} }
}); });
@ -118,111 +128,149 @@ export default function LoginForm(props) {
const onSubmit2FA = useCallback(() => { const onSubmit2FA = useCallback(() => {
setLoggingIn(true); setLoggingIn(true);
props.onTotp2FA(tfaCode, (res) => { api.verifyTotp2FA(tfaCode).then((res) => {
setLoggingIn(false); setLoggingIn(false);
if (res.success) {
set2FAToken({ ...tfaToken, authenticated: true });
props.onLogin();
} else {
set2FAToken({ ...tfaToken, step: 2, error: res.msg });
}
}); });
}, [tfaCode, props]); }, [tfaCode, tfaToken, props]);
const onCancel2FA = useCallback(() => { const onCancel2FA = useCallback(() => {
abortController.abort();
props.onLogout(); props.onLogout();
}, [props]); set2FAToken({authenticated: false, step: 0, error: ""});
}, [props, abortController]);
useEffect(() => { useEffect(() => {
if (!api.loggedIn || !api.user) { if (!api.loggedIn) {
return; return;
} }
let twoFactor = api.user["2fa"]; if (!tfaToken || !tfaToken.confirmed || tfaToken.authenticated || tfaToken.type !== "fido") {
if (!twoFactor || !twoFactor.confirmed ||
twoFactor.authenticated || twoFactor.type !== "fido") {
return; return;
} }
if (tfaState === 0) { let step = tfaToken.step || 0;
set2FAState(1); if (step !== 0) {
set2FAError(""); return;
navigator.credentials.get({ }
publicKey: {
challenge: encodeText(window.atob(twoFactor.challenge)), set2FAToken({ ...tfaToken, step: 1, error: "" });
allowCredentials: [{ navigator.credentials.get({
id: encodeText(window.atob(twoFactor.credentialID)), publicKey: {
type: "public-key", challenge: encodeText(window.atob(tfaToken.challenge)),
}], allowCredentials: [{
userVerification: "discouraged", id: encodeText(window.atob(tfaToken.credentialID)),
}, type: "public-key",
}).then((res) => { }],
let credentialID = res.id; userVerification: "discouraged",
let clientDataJson = decodeText(res.response.clientDataJSON); },
let authData = window.btoa(decodeText(res.response.authenticatorData)); signal: abortSignal
let signature = window.btoa(decodeText(res.response.signature)); }).then((res) => {
props.onKey2FA(credentialID, clientDataJson, authData, signature, res => { let credentialID = res.id;
if (!res.success) { let clientDataJson = decodeText(res.response.clientDataJSON);
set2FAState(2); let authData = window.btoa(decodeText(res.response.authenticatorData));
} let signature = window.btoa(decodeText(res.response.signature));
}); api.verifyKey2FA(credentialID, clientDataJson, authData, signature).then((res) => {
}).catch(e => { if (!res.success) {
set2FAState(2); set2FAToken({ ...tfaToken, step: 2, error: res.msg });
set2FAError(e.toString()); } else {
props.onLogin();
}
}); });
} }).catch(e => {
}, [api.loggedIn, api.user, tfaState, props]); set2FAToken({ ...tfaToken, step: 2, error: e.toString() });
});
}, [api.loggedIn, tfaToken, props.onLogin, props.onKey2FA, abortSignal]);
const createForm = () => { const createForm = () => {
// 2FA // 2FA
if (api.loggedIn && api.user["2fa"]) { if (api.loggedIn && tfaToken.type) {
return <>
<div>{L("account.2fa_title")}: {api.user["2fa"].type}</div> if (tfaToken.type === "totp") {
{ api.user["2fa"].type === "totp" ? return <>
<div>{L("account.2fa_title")}:</div>
<TextField <TextField
variant="outlined" margin="normal" variant={"outlined"} margin={"normal"}
id="code" label={L("account.6_digit_code")} name="code" id={"code"} label={L("account.6_digit_code")} name={"code"}
autoComplete="code" autoComplete={"code"}
required fullWidth autoFocus required fullWidth autoFocus
value={tfaCode} onChange={(e) => set2FACode(e.target.value)} value={tfaCode} onChange={(e) => set2FACode(e.target.value)}
/> : <> />
{L("account.2fa_text")} {
<Box mt={2} textAlign={"center"}> tfaToken.error ? <Alert severity="error">{tfaToken.error}</Alert> : <></>
{tfaState !== 2 }
? <CircularProgress/> <Grid container spacing={2} className={classes.buttons2FA}>
: <div className={classes.error2FA}> <Grid item xs={6}>
<div>{L("general.something_went_wrong")}:<br />{tfaError}</div> <Button
<Button onClick={() => set2FAState(0)} fullWidth variant={"contained"}
variant={"outlined"} color={"secondary"} size={"small"}> color={"inherit"} size={"medium"}
<ReplayIcon />&nbsp;{L("general.retry")} disabled={isLoggingIn}
</Button> onClick={onCancel2FA}>
</div> {L("general.go_back")}
} </Button>
</Box> </Grid>
</> <Grid item xs={6}>
} <Button
{ type="submit" fullWidth variant="contained"
error ? <Alert severity="error">{error}</Alert> : <></> color="primary" size={"medium"}
} disabled={isLoggingIn || tfaToken.type !== "totp"}
<Grid container spacing={2} className={classes.buttons2FA}> onClick={onSubmit2FA}>
<Grid item xs={6}> {isLoggingIn ?
<Button <>{L("general.submitting")} <CircularProgress size={15}/></> :
fullWidth variant="contained" L("general.submit")
color="inherit" size={"medium"} }
disabled={isLoggingIn} </Button>
onClick={onCancel2FA}> </Grid>
{L("general.go_back")}
</Button>
</Grid> </Grid>
<Grid item xs={6}> </>
<Button } else if (tfaToken.type === "fido") {
type="submit" fullWidth variant="contained" return <>
color="primary" size={"medium"} <div>{L("account.2fa_title")}:</div>
disabled={isLoggingIn || api.user["2fa"].type !== "totp"} <br />
onClick={onSubmit2FA}> {L("account.2fa_text")}
{isLoggingIn ? <Box mt={2} textAlign={"center"}>
<>{L("general.submitting")} <CircularProgress size={15}/></> : {tfaToken.step !== 2
L("general.submit") ? <CircularProgress/>
} : <div className={classes.error2FA}>
</Button> <div><b>{L("general.something_went_wrong")}:</b><br />{tfaToken.error}</div>
<Button onClick={() => set2FAToken({ ...tfaToken, step: 0, error: "" })}
variant={"outlined"} color={"secondary"} size={"small"}>
<ReplayIcon />&nbsp;{L("general.retry")}
</Button>
</div>
}
</Box>
<Grid container spacing={2} className={classes.buttons2FA}>
<Grid item xs={6}>
<Button
fullWidth variant={"contained"}
color={"inherit"} size={"medium"}
disabled={isLoggingIn}
onClick={onCancel2FA}>
{L("general.go_back")}
</Button>
</Grid>
<Grid item xs={6}>
<Button
type="submit" fullWidth variant="contained"
color="primary" size={"medium"}
disabled={isLoggingIn || tfaToken.type !== "totp"}
onClick={onSubmit2FA}>
{isLoggingIn ?
<>{L("general.submitting")} <CircularProgress size={15}/></> :
L("general.submit")
}
</Button>
</Grid>
</Grid> </Grid>
</Grid> </>
</> }
} }
return <> return <>