frontend & backend update
This commit is contained in:
parent
1d6ff17994
commit
4cec531a25
@ -18,6 +18,7 @@ namespace Core\API\ApiKey {
|
||||
use Core\API\Parameter\Parameter;
|
||||
use Core\Driver\SQL\Condition\Compare;
|
||||
use Core\Driver\SQL\Condition\CondAnd;
|
||||
use Core\Driver\SQL\Query\Insert;
|
||||
use Core\Objects\Context;
|
||||
use Core\Objects\DatabaseEntity\ApiKey;
|
||||
|
||||
@ -46,6 +47,10 @@ namespace Core\API\ApiKey {
|
||||
|
||||
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 {
|
||||
@ -82,6 +87,10 @@ namespace Core\API\ApiKey {
|
||||
|
||||
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 {
|
||||
@ -112,6 +121,10 @@ namespace Core\API\ApiKey {
|
||||
|
||||
return $this->success;
|
||||
}
|
||||
|
||||
public static function getDefaultACL(Insert $insert): void {
|
||||
$insert->addRow(self::getEndpoint(), [], "Allows users to refresh API-Key");
|
||||
}
|
||||
}
|
||||
|
||||
class Revoke extends ApiKeyAPI {
|
||||
@ -138,5 +151,9 @@ namespace Core\API\ApiKey {
|
||||
|
||||
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\Parameter\StringType;
|
||||
use Core\Driver\SQL\Query\Insert;
|
||||
use Core\Objects\Context;
|
||||
use Core\Objects\DatabaseEntity\Controller\DatabaseEntity;
|
||||
use Core\Objects\DatabaseEntity\Group;
|
||||
|
||||
class Status extends DatabaseAPI {
|
||||
|
||||
@ -29,6 +31,10 @@ namespace Core\API\Database {
|
||||
|
||||
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 {
|
||||
@ -98,5 +104,9 @@ namespace Core\API\Database {
|
||||
|
||||
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\Count;
|
||||
use Core\Driver\SQL\Join\InnerJoin;
|
||||
use Core\Driver\SQL\Query\Insert;
|
||||
use Core\Objects\Context;
|
||||
use Core\Objects\DatabaseEntity\Controller\NMRelation;
|
||||
use Core\Objects\DatabaseEntity\Group;
|
||||
use Core\Objects\DatabaseEntity\User;
|
||||
|
||||
@ -83,6 +83,10 @@ namespace Core\API\Groups {
|
||||
|
||||
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 {
|
||||
@ -106,6 +110,10 @@ namespace Core\API\Groups {
|
||||
|
||||
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 {
|
||||
@ -142,6 +150,9 @@ namespace Core\API\Groups {
|
||||
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 {
|
||||
@ -182,6 +193,10 @@ namespace Core\API\Groups {
|
||||
|
||||
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 {
|
||||
@ -209,6 +224,10 @@ namespace Core\API\Groups {
|
||||
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 {
|
||||
@ -247,6 +266,10 @@ namespace Core\API\Groups {
|
||||
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 {
|
||||
@ -285,6 +308,9 @@ namespace Core\API\Groups {
|
||||
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\Condition\Compare;
|
||||
use Core\Driver\SQL\Condition\CondIn;
|
||||
use Core\Driver\SQL\Query\Insert;
|
||||
use Core\Objects\Context;
|
||||
use Core\Objects\DatabaseEntity\Group;
|
||||
use Core\Objects\DatabaseEntity\SystemLog;
|
||||
|
||||
class Get extends LogsAPI {
|
||||
@ -118,6 +120,10 @@ namespace Core\API\Logs {
|
||||
|
||||
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\Parameter\Parameter;
|
||||
use Core\API\Parameter\StringType;
|
||||
use Core\Driver\SQL\Query\Insert;
|
||||
use Core\Objects\DatabaseEntity\Group;
|
||||
use Core\Objects\DatabaseEntity\MailQueueItem;
|
||||
use DateTimeInterface;
|
||||
use Core\Driver\SQL\Condition\Compare;
|
||||
@ -78,6 +80,10 @@ namespace Core\API\Mail {
|
||||
$this->lastError = $req->getLastError();
|
||||
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 {
|
||||
|
@ -31,6 +31,7 @@ namespace Core\API\Permission {
|
||||
use Core\Driver\SQL\Condition\CondIn;
|
||||
use Core\Driver\SQL\Condition\CondLike;
|
||||
use Core\Driver\SQL\Condition\CondNot;
|
||||
use Core\Driver\SQL\Query\Insert;
|
||||
use Core\Driver\SQL\Strategy\UpdateStrategy;
|
||||
use Core\Objects\Context;
|
||||
use Core\Objects\DatabaseEntity\Group;
|
||||
@ -73,6 +74,16 @@ namespace Core\API\Permission {
|
||||
http_response_code(401);
|
||||
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;
|
||||
@ -127,6 +138,10 @@ namespace Core\API\Permission {
|
||||
|
||||
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 {
|
||||
@ -192,5 +207,10 @@ namespace Core\API\Permission {
|
||||
|
||||
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;
|
||||
|
||||
use Core\Driver\Logger\Logger;
|
||||
use Core\Driver\SQL\Query\Insert;
|
||||
use Core\Objects\Context;
|
||||
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 {
|
||||
|
||||
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) {
|
||||
unset($this->allowedMethods[$key]);
|
||||
}
|
||||
@ -76,7 +71,7 @@ abstract class Request {
|
||||
return $this->isDisabled;
|
||||
}
|
||||
|
||||
protected function allowMethod($method) {
|
||||
protected function allowMethod($method): void {
|
||||
$availableMethods = ["GET", "HEAD", "POST", "PUT", "DELETE", "PATCH", "TRACE", "CONNECT"];
|
||||
if (in_array($method, $availableMethods) && !in_array($method, $this->allowedMethods)) {
|
||||
$this->allowedMethods[] = $method;
|
||||
@ -113,7 +108,7 @@ abstract class Request {
|
||||
return true;
|
||||
}
|
||||
|
||||
public function parseVariableParams($values) {
|
||||
public function parseVariableParams($values): void {
|
||||
foreach ($values as $name => $value) {
|
||||
if (isset($this->params[$name])) continue;
|
||||
$type = Parameter\Parameter::parseType($value);
|
||||
@ -129,6 +124,7 @@ abstract class Request {
|
||||
}
|
||||
|
||||
protected abstract function _execute(): bool;
|
||||
public static function getDefaultACL(Insert $insert): void { }
|
||||
|
||||
public final function execute($values = array()): bool {
|
||||
|
||||
@ -203,12 +199,14 @@ abstract class Request {
|
||||
} else if ($session) {
|
||||
$tfaToken = $session->getUser()->getTwoFactorToken();
|
||||
if ($tfaToken && $tfaToken->isConfirmed() && !$tfaToken->isAuthenticated()) {
|
||||
if (!($this instanceof \Core\API\Tfa\VerifyTotp) && !($this instanceof \Core\API\Tfa\VerifyKey)) {
|
||||
$this->lastError = '2FA-Authorization is required';
|
||||
http_response_code(401);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CSRF Token
|
||||
if ($this->csrfTokenRequired && $session) {
|
||||
@ -225,7 +223,7 @@ abstract class Request {
|
||||
// Check for permission
|
||||
if (!($this instanceof \Core\API\Permission\Save)) {
|
||||
$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();
|
||||
if (!$this->success) {
|
||||
return false;
|
||||
@ -266,11 +264,11 @@ abstract class Request {
|
||||
}
|
||||
|
||||
protected function getParam($name, $obj = NULL): mixed {
|
||||
// I don't know why phpstorm
|
||||
if ($obj === NULL) {
|
||||
$obj = $this->params;
|
||||
}
|
||||
|
||||
// I don't know why phpstorm
|
||||
return (isset($obj[$name]) ? $obj[$name]->value : NULL);
|
||||
}
|
||||
|
||||
@ -302,10 +300,30 @@ abstract class Request {
|
||||
return $this->externalCall;
|
||||
}
|
||||
|
||||
private function getMethod() {
|
||||
$class = str_replace("\\", "/", get_class($this));
|
||||
$class = substr($class, strlen("api/"));
|
||||
return $class;
|
||||
public static function getEndpoint(string $prefix = ""): ?string {
|
||||
$reflectionClass = new \ReflectionClass(get_called_class());
|
||||
if ($reflectionClass->isAbstract()) {
|
||||
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 {
|
||||
@ -314,7 +332,7 @@ abstract class Request {
|
||||
return json_encode($this->result);
|
||||
}
|
||||
|
||||
protected function disableOutputBuffer() {
|
||||
protected function disableOutputBuffer(): void {
|
||||
ob_implicit_flush(true);
|
||||
$levels = ob_get_level();
|
||||
for ( $i = 0; $i < $levels; $i ++ ) {
|
||||
@ -323,7 +341,7 @@ abstract class Request {
|
||||
flush();
|
||||
}
|
||||
|
||||
protected function disableCache() {
|
||||
protected function disableCache(): void {
|
||||
header("Last-Modified: " . (new \DateTime())->format("D, d M Y H:i:s T"));
|
||||
header("Expires: Sat, 26 Jul 1997 05:00:00 GMT");
|
||||
header("Cache-Control: no-store, no-cache, must-revalidate, max-age=0");
|
||||
@ -331,7 +349,7 @@ abstract class Request {
|
||||
header("Pragma: no-cache");
|
||||
}
|
||||
|
||||
protected function setupSSE() {
|
||||
protected function setupSSE(): void {
|
||||
$this->context->sendCookies();
|
||||
$this->context->getSQL()?->close();
|
||||
set_time_limit(0);
|
||||
@ -348,7 +366,7 @@ abstract class Request {
|
||||
* @throws \PhpMqtt\Client\Exceptions\DataTransferException
|
||||
* @throws \PhpMqtt\Client\Exceptions\MqttClientException
|
||||
*/
|
||||
protected function startMqttSSE(MqttClient $mqtt, callable $onPing) {
|
||||
protected function startMqttSSE(MqttClient $mqtt, callable $onPing): void {
|
||||
$lastPing = 0;
|
||||
$mqtt->registerLoopEventHandler(function(MqttClient $mqtt, $elapsed) use (&$lastPing, $onPing) {
|
||||
if ($elapsed - $lastPing >= 5) {
|
||||
@ -366,7 +384,7 @@ abstract class Request {
|
||||
$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)) {
|
||||
return $this->createError("You need to upload an image.");
|
||||
} else if (count($_FILES) > 1) {
|
||||
@ -470,4 +488,49 @@ abstract class Request {
|
||||
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\Driver\SQL\Condition\Compare;
|
||||
use Core\Driver\SQL\Condition\CondBool;
|
||||
use Core\Driver\SQL\Query\Insert;
|
||||
use Core\Driver\SQL\Query\StartTransaction;
|
||||
use Core\Objects\Context;
|
||||
use Core\Objects\DatabaseEntity\Group;
|
||||
use Core\Objects\DatabaseEntity\Route;
|
||||
use Core\Objects\Router\DocumentRoute;
|
||||
use Core\Objects\Router\RedirectPermanentlyRoute;
|
||||
@ -101,6 +103,10 @@ namespace Core\API\Routes {
|
||||
|
||||
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 {
|
||||
@ -202,6 +208,10 @@ namespace Core\API\Routes {
|
||||
|
||||
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 {
|
||||
@ -236,6 +246,10 @@ namespace Core\API\Routes {
|
||||
$this->lastError = $sql->getLastError();
|
||||
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 {
|
||||
@ -270,10 +284,14 @@ namespace Core\API\Routes {
|
||||
$exact = $this->getParam("exact");
|
||||
$active = $this->getParam("active");
|
||||
if ($route->getType() !== $type) {
|
||||
if (!$route->delete($sql)) {
|
||||
return false;
|
||||
} else {
|
||||
$route = $this->createRoute($type, $pattern, $target, $extra, $exact, $active);
|
||||
if ($route === null) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$route->setPattern($pattern);
|
||||
$route->setActive($active);
|
||||
@ -286,6 +304,10 @@ namespace Core\API\Routes {
|
||||
$this->lastError = $sql->getLastError();
|
||||
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 {
|
||||
@ -311,6 +333,10 @@ namespace Core\API\Routes {
|
||||
$this->lastError = $sql->getLastError();
|
||||
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 {
|
||||
@ -325,6 +351,10 @@ namespace Core\API\Routes {
|
||||
$id = $this->getParam("id");
|
||||
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 {
|
||||
@ -339,6 +369,10 @@ namespace Core\API\Routes {
|
||||
$id = $this->getParam("id");
|
||||
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 {
|
||||
@ -380,6 +414,10 @@ namespace Core\API\Routes {
|
||||
public function getRouter(): ?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\CondNot;
|
||||
use Core\Driver\SQL\Condition\CondRegex;
|
||||
use Core\Driver\SQL\Query\Insert;
|
||||
use Core\Driver\SQL\Strategy\UpdateStrategy;
|
||||
use Core\Objects\Context;
|
||||
use Core\Objects\DatabaseEntity\Group;
|
||||
|
||||
class Get extends SettingsAPI {
|
||||
|
||||
@ -46,6 +48,10 @@ namespace Core\API\Settings {
|
||||
|
||||
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 {
|
||||
@ -144,6 +150,10 @@ namespace Core\API\Settings {
|
||||
$this->lastError = $sql->getLastError();
|
||||
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 {
|
||||
@ -173,5 +183,9 @@ namespace Core\API\Settings {
|
||||
$this->result["jwt_public_key"] = $settings->getJwtPublicKey(false)?->getKeyMaterial();
|
||||
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\Distinct;
|
||||
use Core\Driver\SQL\Query\Insert;
|
||||
use Core\Objects\DatabaseEntity\Group;
|
||||
use DateTime;
|
||||
use Core\Driver\SQL\Condition\Compare;
|
||||
use Core\Driver\SQL\Condition\CondBool;
|
||||
@ -113,4 +115,7 @@ class Stats extends Request {
|
||||
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,57 +19,8 @@ class Swagger extends Request {
|
||||
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 {
|
||||
$req = new Permission\Fetch($this->context);
|
||||
$req = new \Core\API\Permission\Fetch($this->context);
|
||||
$this->success = $req->execute();
|
||||
$permissions = [];
|
||||
foreach ($req->getResult()["permissions"] as $permission) {
|
||||
@ -85,12 +36,13 @@ class Swagger extends Request {
|
||||
}
|
||||
|
||||
$currentUser = $this->context->getUser();
|
||||
if (($request->loginRequired() || !empty($requiredGroups)) && !$currentUser) {
|
||||
$isLoggedIn = $currentUser !== null;
|
||||
if (($request->loginRequired() || !empty($requiredGroups)) && !$isLoggedIn) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
@ -36,17 +36,20 @@ namespace Core\API {
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function verifyClientDataJSON($jsonData, KeyBasedTwoFactorToken $token): bool {
|
||||
protected function verifyClientDataJSON(array $jsonData, KeyBasedTwoFactorToken $token): bool {
|
||||
$settings = $this->context->getSettings();
|
||||
$expectedType = $token->isConfirmed() ? "webauthn.get" : "webauthn.create";
|
||||
$type = $jsonData["type"] ?? "null";
|
||||
if ($type !== $expectedType) {
|
||||
return $this->createError("Invalid client data json type. Expected: '$expectedType', Got: '$type'");
|
||||
} else if ($token->getData() !== base64url_decode($jsonData["challenge"] ?? "")) {
|
||||
} else if (base64url_decode($token->getChallenge()) !== base64url_decode($jsonData["challenge"] ?? "")) {
|
||||
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();
|
||||
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;
|
||||
@ -96,6 +99,8 @@ namespace Core\API\TFA {
|
||||
|
||||
if ($this->success && $token->isConfirmed()) {
|
||||
// send an email
|
||||
$email = $currentUser->getEmail();
|
||||
if ($email) {
|
||||
$settings = $this->context->getSettings();
|
||||
$req = new \Core\API\Template\Render($this->context);
|
||||
$this->success = $req->execute([
|
||||
@ -113,7 +118,7 @@ namespace Core\API\TFA {
|
||||
$siteName = $settings->getSiteName();
|
||||
$req = new \Core\API\Mail\Send($this->context);
|
||||
$this->success = $req->execute([
|
||||
"to" => $currentUser->getEmail(),
|
||||
"to" => $email,
|
||||
"subject" => "[$siteName] 2FA-Authentication removed",
|
||||
"body" => $body,
|
||||
"gpgFingerprint" => $gpg?->getFingerprint()
|
||||
@ -122,6 +127,7 @@ namespace Core\API\TFA {
|
||||
|
||||
$this->lastError = $req->getLastError();
|
||||
}
|
||||
}
|
||||
|
||||
return $this->success;
|
||||
}
|
||||
@ -211,6 +217,8 @@ namespace Core\API\TFA {
|
||||
return $this->createError("Invalid 2FA-token endpoint");
|
||||
}
|
||||
|
||||
$this->result["time"] = time();
|
||||
$this->result["time_zone"] = $this->context->getSettings()->getTimeZone();
|
||||
$code = $this->getParam("code");
|
||||
if (!$twoFactorToken->verify($code)) {
|
||||
return $this->createError("Code does not match");
|
||||
@ -250,11 +258,11 @@ namespace Core\API\TFA {
|
||||
if (!($twoFactorToken instanceof KeyBasedTwoFactorToken) || $twoFactorToken->isConfirmed()) {
|
||||
return $this->createError("You already added a two factor token");
|
||||
} else {
|
||||
$challenge = base64_encode($twoFactorToken->getData());
|
||||
$challenge = $twoFactorToken->getChallenge();
|
||||
}
|
||||
} else {
|
||||
$challenge = base64_encode(generateRandomString(32, "raw"));
|
||||
$twoFactorToken = new KeyBasedTwoFactorToken($challenge);
|
||||
$twoFactorToken = KeyBasedTwoFactorToken::create();
|
||||
$challenge = $twoFactorToken->getChallenge();
|
||||
$this->success = ($twoFactorToken->save($sql) !== false);
|
||||
$this->lastError = $sql->getLastError();
|
||||
if (!$this->success) {
|
||||
@ -262,7 +270,7 @@ namespace Core\API\TFA {
|
||||
}
|
||||
|
||||
$currentUser->setTwoFactorToken($twoFactorToken);
|
||||
$this->success = $currentUser->save($sql) !== false;
|
||||
$this->success = $currentUser->save($sql, ["twoFactorToken"]) !== false;
|
||||
$this->lastError = $sql->getLastError();
|
||||
if (!$this->success) {
|
||||
return false;
|
||||
|
@ -8,15 +8,16 @@ use Core\Driver\SQL\Condition\Condition;
|
||||
use Core\Driver\SQL\SQL;
|
||||
use Core\Objects\DatabaseEntity\Controller\DatabaseEntityHandler;
|
||||
use Core\Objects\DatabaseEntity\Controller\DatabaseEntityQuery;
|
||||
use Core\Objects\DatabaseEntity\User;
|
||||
|
||||
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;
|
||||
$defaultOrderBy = $defaultOrderBy ?? current($orderColumns);
|
||||
|
||||
return [
|
||||
'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),
|
||||
'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\CondOr;
|
||||
use Core\Driver\SQL\Expression\Alias;
|
||||
use Core\Driver\SQL\Query\Insert;
|
||||
use Core\Objects\DatabaseEntity\Group;
|
||||
use Core\Objects\DatabaseEntity\UserToken;
|
||||
use Core\Driver\SQL\Column\Column;
|
||||
@ -182,6 +183,9 @@ namespace Core\API\User {
|
||||
|
||||
$groups = [];
|
||||
$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"));
|
||||
if (!empty($requestedGroups)) {
|
||||
$groups = Group::findAll($sql, new CondIn(new Column("id"), $requestedGroups));
|
||||
@ -206,6 +210,10 @@ namespace Core\API\User {
|
||||
public function getUser(): 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 {
|
||||
@ -263,6 +271,10 @@ namespace Core\API\User {
|
||||
|
||||
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 {
|
||||
@ -302,6 +314,10 @@ namespace Core\API\User {
|
||||
|
||||
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 {
|
||||
@ -314,31 +330,35 @@ namespace Core\API\User {
|
||||
public function _execute(): bool {
|
||||
|
||||
$currentUser = $this->context->getUser();
|
||||
$language = $this->context->getLanguage();
|
||||
$this->result["language"] = $language->jsonSerialize();
|
||||
|
||||
if (!$currentUser) {
|
||||
$this->result["loggedIn"] = false;
|
||||
$userGroups = [];
|
||||
} else {
|
||||
$this->result["loggedIn"] = true;
|
||||
$userGroups = array_keys($currentUser->getGroups());
|
||||
$this->result["user"] = $currentUser->jsonSerialize();
|
||||
$this->result["session"] = $this->context->getSession()->jsonSerialize();
|
||||
}
|
||||
|
||||
|
||||
$sql = $this->context->getSQL();
|
||||
$res = $sql->select("method", "groups")
|
||||
->from("ApiPermission")
|
||||
->execute();
|
||||
|
||||
$permissions = [];
|
||||
$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))) {
|
||||
$permissions[] = $row["method"];
|
||||
$this->result["permissions"][] = $row["method"];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->result["permissions"] = $permissions;
|
||||
$this->result["user"] = $currentUser->jsonSerialize();
|
||||
$this->result["session"] = $this->context->getSession()->jsonSerialize();
|
||||
}
|
||||
|
||||
return $this->success;
|
||||
}
|
||||
}
|
||||
@ -415,6 +435,10 @@ namespace Core\API\User {
|
||||
$this->logger->info("Created new user with id=" . $user->getId());
|
||||
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 {
|
||||
@ -526,6 +550,14 @@ namespace Core\API\User {
|
||||
if ($this->context->getUser()) {
|
||||
$this->lastError = L('You are already logged in');
|
||||
$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;
|
||||
}
|
||||
|
||||
@ -552,16 +584,19 @@ namespace Core\API\User {
|
||||
return $this->createError("Error creating Session: " . $sql->getLastError());
|
||||
} else {
|
||||
$tfaToken = $user->getTwoFactorToken();
|
||||
|
||||
$this->result["loggedIn"] = true;
|
||||
$this->result["user"] = $user->jsonSerialize();
|
||||
$this->result["session"] = $session->jsonSerialize();
|
||||
$this->result["logoutIn"] = $session->getExpiresSeconds();
|
||||
$this->result["csrfToken"] = $session->getCsrfToken();
|
||||
if ($tfaToken && $tfaToken->isConfirmed()) {
|
||||
$this->result["2fa"] = ["type" => $tfaToken->getType()];
|
||||
if ($tfaToken instanceof KeyBasedTwoFactorToken) {
|
||||
$challenge = base64_encode(generateRandomString(32, "raw"));
|
||||
$this->result["2fa"]["challenge"] = $challenge;
|
||||
$_SESSION["challenge"] = $challenge;
|
||||
$tfaToken->generateChallenge();
|
||||
}
|
||||
|
||||
$this->result["twoFactorToken"] = $tfaToken->jsonSerialize([
|
||||
"type", "challenge", "authenticated", "confirmed", "credentialID"
|
||||
]);
|
||||
}
|
||||
$this->success = true;
|
||||
}
|
||||
@ -823,6 +858,10 @@ namespace Core\API\User {
|
||||
|
||||
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 {
|
||||
@ -856,6 +895,10 @@ namespace Core\API\User {
|
||||
|
||||
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 {
|
||||
|
@ -17,6 +17,8 @@ namespace Core\API\Visitors {
|
||||
use Core\API\Parameter\StringType;
|
||||
use Core\API\VisitorsAPI;
|
||||
use Core\Driver\SQL\Expression\Count;
|
||||
use Core\Driver\SQL\Query\Insert;
|
||||
use Core\Objects\DatabaseEntity\Group;
|
||||
use DateTime;
|
||||
use Core\Driver\SQL\Condition\Compare;
|
||||
use Core\Driver\SQL\Expression\Add;
|
||||
@ -112,5 +114,9 @@ namespace Core\API\Visitors {
|
||||
|
||||
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;
|
||||
|
||||
use Core\API\Request;
|
||||
use Core\Driver\SQL\SQL;
|
||||
use Core\Objects\DatabaseEntity\Controller\DatabaseEntity;
|
||||
use Core\Objects\DatabaseEntity\Controller\DatabaseEntityHandler;
|
||||
use Core\Objects\DatabaseEntity\Group;
|
||||
use Core\Objects\DatabaseEntity\Language;
|
||||
use Core\Objects\DatabaseEntity\Route;
|
||||
use Core\Objects\Router\DocumentRoute;
|
||||
use Core\Objects\Router\StaticFileRoute;
|
||||
@ -53,35 +52,7 @@ class CreateDatabase extends DatabaseScript {
|
||||
->addString("description", 128, false, "")
|
||||
->primaryKey("method");
|
||||
|
||||
$queries[] = $sql->insert("ApiPermission", array("method", "groups", "description"))
|
||||
->addRow("ApiKey/create", array(), "Allows users to create API-Keys for themselves")
|
||||
->addRow("ApiKey/fetch", array(), "Allows users to list their API-Keys")
|
||||
->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::loadDefaultACL($queries, $sql);
|
||||
self::loadPatches($queries, $sql);
|
||||
|
||||
return $queries;
|
||||
@ -108,7 +79,7 @@ class CreateDatabase extends DatabaseScript {
|
||||
}
|
||||
}
|
||||
|
||||
public static function loadEntities(&$queries, $sql) {
|
||||
private static function loadEntities(&$queries, $sql) {
|
||||
$persistables = [];
|
||||
$baseDirs = ["Core", "Site"];
|
||||
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 {
|
||||
|
||||
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_INFO = 1;
|
||||
public const LOG_LEVEL_WARNING = 2;
|
||||
|
@ -7,8 +7,8 @@ use Core\Driver\SQL\SQL;
|
||||
|
||||
class CondIn extends Condition {
|
||||
|
||||
private $needle;
|
||||
private $haystack;
|
||||
private mixed $needle;
|
||||
private mixed $haystack;
|
||||
|
||||
public function __construct($needle, $haystack) {
|
||||
$this->needle = $needle;
|
||||
@ -20,22 +20,28 @@ class CondIn extends Condition {
|
||||
|
||||
function getExpression(SQL $sql, array &$params): string {
|
||||
|
||||
$haystack = $this->getHaystack();
|
||||
if (is_array($haystack)) {
|
||||
$needle = $sql->addValue($this->needle, $params);
|
||||
|
||||
if (is_array($this->haystack)) {
|
||||
if (!empty($this->haystack)) {
|
||||
$values = array();
|
||||
foreach ($haystack as $value) {
|
||||
foreach ($this->haystack as $value) {
|
||||
$values[] = $sql->addValue($value, $params);
|
||||
}
|
||||
|
||||
$values = implode(",", $values);
|
||||
$values = "($values)";
|
||||
} else if($haystack instanceof Select) {
|
||||
$values = $haystack->getExpression($sql, $params);
|
||||
} else {
|
||||
$sql->getLogger()->error("Unsupported in-expression value: " . get_class($haystack));
|
||||
$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 {
|
||||
$sql->getLogger()->error("Unsupported in-expression value: " . get_class($this->haystack));
|
||||
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\Driver\Logger\Logger;
|
||||
use Core\Driver\SQL\Condition\Compare;
|
||||
use Core\Driver\SQL\Condition\CondLike;
|
||||
use Core\Driver\SQL\Expression\Count;
|
||||
@ -138,7 +139,7 @@ class MySQL extends SQL {
|
||||
/**
|
||||
* @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;
|
||||
$this->lastError = "";
|
||||
@ -146,6 +147,10 @@ class MySQL extends SQL {
|
||||
$res = null;
|
||||
$success = false;
|
||||
|
||||
if ($logLevel === Logger::LOG_LEVEL_DEBUG) {
|
||||
$this->logger->debug("query: " . $query . ", args: " . json_encode($values), false);
|
||||
}
|
||||
|
||||
try {
|
||||
if (empty($values)) {
|
||||
$res = mysqli_query($this->connection, $query);
|
||||
@ -167,7 +172,6 @@ class MySQL extends SQL {
|
||||
}
|
||||
}
|
||||
} else if ($stmt = $this->connection->prepare($query)) {
|
||||
|
||||
$sqlParams = $this->getPreparedParams($values);
|
||||
if ($stmt->bind_param(...$sqlParams)) {
|
||||
if ($stmt->execute()) {
|
||||
@ -201,10 +205,12 @@ class MySQL extends SQL {
|
||||
}
|
||||
}
|
||||
} catch (\mysqli_sql_exception $exception) {
|
||||
if ($logLevel >= Logger::LOG_LEVEL_ERROR) {
|
||||
$this->lastError = $this->logger->error("MySQL::execute failed: " .
|
||||
($stmt !== null
|
||||
? "$stmt->error ($stmt->errno)"
|
||||
: $exception->getMessage()));
|
||||
}
|
||||
} finally {
|
||||
|
||||
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\Driver\Logger\Logger;
|
||||
use Core\Driver\SQL\Column\Column;
|
||||
use Core\Driver\SQL\Column\IntColumn;
|
||||
use Core\Driver\SQL\Column\NumericColumn;
|
||||
@ -96,12 +97,16 @@ class PostgreSQL extends SQL {
|
||||
/**
|
||||
* @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 = "";
|
||||
$stmt_name = uniqid();
|
||||
$pgParams = array();
|
||||
|
||||
if ($logLevel === Logger::LOG_LEVEL_DEBUG) {
|
||||
$this->logger->debug("query: " . $query . ", args: " . json_encode($values), false);
|
||||
}
|
||||
|
||||
if (!is_null($values)) {
|
||||
foreach ($values as $value) {
|
||||
$paramType = Parameter::parseType($value);
|
||||
|
@ -40,6 +40,7 @@ class Insert extends Query {
|
||||
public function getTableName(): string { return $this->tableName; }
|
||||
public function getColumns(): array { return $this->columns; }
|
||||
public function getRows(): array { return $this->rows; }
|
||||
public function hasRows(): bool { return !empty($this->rows); }
|
||||
public function onDuplicateKey(): ?Strategy { return $this->onDuplicateKey; }
|
||||
public function getReturning(): ?string { return $this->returning; }
|
||||
|
||||
|
@ -149,7 +149,12 @@ abstract class SQL {
|
||||
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);
|
||||
|
||||
// fetch generated serial ids for Insert statements
|
||||
@ -273,7 +278,7 @@ abstract class SQL {
|
||||
/**
|
||||
* @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 {
|
||||
|
||||
|
@ -13,7 +13,7 @@ return [
|
||||
"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_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_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",
|
||||
@ -23,10 +23,14 @@ return [
|
||||
"email" => "E-Mail Adresse",
|
||||
"password" => "Passwort",
|
||||
"password_confirm" => "Passwort bestätigen",
|
||||
"password_old" => "Altes Passwort",
|
||||
"password_new" => "Neues Passwort",
|
||||
"full_name" => "Voller Name",
|
||||
"remember_me" => "Eingeloggt bleiben",
|
||||
"signing_in" => "Einloggen",
|
||||
"sign_in" => "Einloggen",
|
||||
"forgot_password" => "Passwort vergessen?",
|
||||
"change_password" => "Passwort ändern",
|
||||
"passwords_do_not_match" => "Die Passwörter stimmen nicht überein",
|
||||
"back_to_login" => "Zurück zum Login",
|
||||
"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",
|
||||
"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",
|
||||
"gpg_key" => "GPG-Schlüssel",
|
||||
"2fa_token" => "Zwei-Faktor Authentifizierung (2FA)",
|
||||
];
|
@ -22,22 +22,28 @@ return [
|
||||
"name" => "Name",
|
||||
"type" => "Typ",
|
||||
"size" => "Größe",
|
||||
"last_modified" => "Zuletzt geändert",
|
||||
|
||||
# dialog / actions
|
||||
"action" => "Aktion",
|
||||
"actions" => "Aktionen",
|
||||
"title" => "Titel",
|
||||
"message" => "Nachricht",
|
||||
"rename" => "Umbenennen",
|
||||
"remove" => "Entfernen",
|
||||
"change" => "Bearbeiten",
|
||||
"reset" => "Zurücksetzen",
|
||||
"move" => "Verschieben",
|
||||
"delete" => "Löschen",
|
||||
"info" => "Info",
|
||||
"reload" => "Aktualisieren",
|
||||
|
||||
# file
|
||||
"choose_file" => "Datei auswählen",
|
||||
"download" => "Herunterladen",
|
||||
"download_all" => "Alles Herunterladen",
|
||||
"upload" => "Hochladen",
|
||||
"uploading" => "Lade hoch",
|
||||
"overwrite" => "Überschreiben",
|
||||
"reload" => "Aktualisieren",
|
||||
|
||||
# data table
|
||||
"showing_x_of_y_entries" => "Zeige %d von %d Einträgen",
|
||||
@ -47,6 +53,8 @@ return [
|
||||
"date" => "Datum",
|
||||
"start_date" => "Startdatum",
|
||||
"end_date" => "Enddatum",
|
||||
"last_modified" => "Zuletzt geändert",
|
||||
"timestamp" => "Zeitpunkt",
|
||||
"date_format" => "d.m.Y",
|
||||
"date_time_format" => "d.m.Y H:i",
|
||||
"date_time_format_precise" => "d.m.Y H:i:s",
|
||||
|
24
Core/Localization/de_DE/settings.php
Normal file
24
Core/Localization/de_DE/settings.php
Normal file
@ -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",
|
||||
"password" => "Password",
|
||||
"password_confirm" => "Confirm Password",
|
||||
"password_old" => "Old Password",
|
||||
"password_new" => "New Password",
|
||||
"full_name" => "Full Name",
|
||||
"remember_me" => "Remember Me",
|
||||
"signing_in" => "Signing in",
|
||||
"sign_in" => "Sign In",
|
||||
"forgot_password" => "Forgot password?",
|
||||
"change_password" => "Change password",
|
||||
"register_text" => "Don't have an account? Sign Up",
|
||||
"passwords_do_not_match" => "Your passwords did not match",
|
||||
"back_to_login" => "Back to Login",
|
||||
@ -38,4 +42,6 @@ return [
|
||||
"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_error" => "Error confirming e-mail address",
|
||||
"gpg_key" => "GPG Key",
|
||||
"2fa_token" => "Two-Factor Authentication (2FA)",
|
||||
];
|
@ -12,10 +12,10 @@ return [
|
||||
"name" => "Name",
|
||||
"type" => "Type",
|
||||
"size" => "Size",
|
||||
"last_modified" => "Last Modified",
|
||||
|
||||
# dialog / actions
|
||||
"action" => "Action",
|
||||
"actions" => "Actions",
|
||||
"title" => "Title",
|
||||
"message" => "Message",
|
||||
"edit" => "Edit",
|
||||
@ -24,12 +24,19 @@ return [
|
||||
"request" => "Request",
|
||||
"cancel" => "Cancel",
|
||||
"confirm" => "Confirm",
|
||||
"remove" => "Remove",
|
||||
"change" => "Change",
|
||||
"reset" => "Reset",
|
||||
"retry" => "Retry",
|
||||
"go_back" => "Go Back",
|
||||
"save" => "Save",
|
||||
"saving" => "Saving",
|
||||
"delete" => "Delete",
|
||||
"info" => "Info",
|
||||
"reload" => "Reload",
|
||||
|
||||
# file
|
||||
"choose_file" => "Choose File",
|
||||
"download" => "Download",
|
||||
"download_all" => "Download All",
|
||||
"upload" => "Upload",
|
||||
@ -37,7 +44,7 @@ return [
|
||||
"rename" => "Rename",
|
||||
"move" => "Move",
|
||||
"overwrite" => "Overwrite",
|
||||
"reload" => "Reload",
|
||||
|
||||
|
||||
# data table
|
||||
"showing_x_of_y_entries" => "Showing %d of %d entries",
|
||||
@ -47,6 +54,8 @@ return [
|
||||
"date" => "Date",
|
||||
"start_date" => "Start Date",
|
||||
"end_date" => "End Date",
|
||||
"last_modified" => "Last Modified",
|
||||
"timestamp" => "Timestamp",
|
||||
"date_format" => "m/d/Y",
|
||||
"date_time_format" => "m/d/Y G:i A",
|
||||
"date_time_format_precise" => "m/d/Y G:i:s A",
|
||||
|
24
Core/Localization/en_US/settings.php
Normal file
24
Core/Localization/en_US/settings.php
Normal file
@ -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(
|
||||
new CondLike("name", "%$lang%"), // english
|
||||
new Compare("code", $lang), // de_DE
|
||||
new CondLike("code", "${lang}_%") // de -> de_%
|
||||
new CondLike("code", "{$lang}_%") // de -> de_%
|
||||
))
|
||||
);
|
||||
if ($language) {
|
||||
|
@ -57,6 +57,12 @@ abstract class DatabaseEntity implements ArrayAccess, JsonSerializable {
|
||||
public function jsonSerialize(?array $propertyNames = null): array {
|
||||
$reflectionClass = (new \ReflectionClass(get_called_class()));
|
||||
$properties = $reflectionClass->getProperties();
|
||||
|
||||
while ($reflectionClass->getParentClass()->getName() !== DatabaseEntity::class) {
|
||||
$reflectionClass = $reflectionClass->getParentClass();
|
||||
$properties = array_merge($reflectionClass->getProperties(), $properties);
|
||||
}
|
||||
|
||||
$ignoredProperties = ["entityLogConfig", "customData"];
|
||||
|
||||
$jsonArray = [];
|
||||
|
@ -524,7 +524,15 @@ class DatabaseEntityHandler implements Persistable {
|
||||
$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);
|
||||
if (empty($entityIds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->nmRelations as $nmProperty => $nmRelation) {
|
||||
$nmTable = $nmRelation->getTableName();
|
||||
$property = $this->properties[$nmProperty];
|
||||
@ -600,7 +612,6 @@ class DatabaseEntityHandler implements Persistable {
|
||||
$otherHandler = $nmRelation->getRelHandler();
|
||||
$thisIdColumn = $otherHandler->getColumnName($nmRelation->getThisProperty(), false);
|
||||
$relIdColumn = $otherHandler->getColumnName($nmRelation->getRefProperty(), false);
|
||||
if (!empty($entityIds)) {
|
||||
$relEntityQuery = DatabaseEntityQuery::fetchAll($otherHandler)
|
||||
->where(new CondIn(new Column($thisIdColumn), $entityIds));
|
||||
|
||||
@ -622,7 +633,6 @@ class DatabaseEntityHandler implements Persistable {
|
||||
$targetArray[$row[$relIdColumn]] = $relEntity;
|
||||
$property->setValue($thisEntity, $targetArray);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$this->logger->error("fetchNMRelations for type '" . get_class($nmRelation) . "' is not implemented");
|
||||
continue;
|
||||
|
@ -94,7 +94,7 @@ class DatabaseEntityQuery extends Select {
|
||||
|
||||
$relIndex = 1;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@ -36,6 +36,13 @@ class NMRelationReference implements Persistable {
|
||||
return $this->refProperty;
|
||||
}
|
||||
|
||||
public function getRefColumns(): array {
|
||||
return [
|
||||
$this->handler->getColumnName($this->getThisProperty(), false),
|
||||
$this->handler->getColumnName($this->getRefProperty(), false),
|
||||
];
|
||||
}
|
||||
|
||||
public function getRelHandler(): DatabaseEntityHandler {
|
||||
return $this->handler;
|
||||
}
|
||||
|
@ -5,6 +5,8 @@ namespace Core\Objects\DatabaseEntity;
|
||||
use Core\Driver\SQL\SQL;
|
||||
use Core\Objects\DatabaseEntity\Attribute\ExtendingEnum;
|
||||
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\TimeBasedTwoFactorToken;
|
||||
use Core\Objects\DatabaseEntity\Controller\DatabaseEntity;
|
||||
@ -18,8 +20,13 @@ abstract class TwoFactorToken extends DatabaseEntity {
|
||||
|
||||
#[ExtendingEnum(self::TWO_FACTOR_TOKEN_TYPES)] private string $type;
|
||||
private bool $confirmed;
|
||||
|
||||
#[Transient]
|
||||
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) {
|
||||
parent::__construct($id);
|
||||
@ -39,6 +46,7 @@ abstract class TwoFactorToken extends DatabaseEntity {
|
||||
|
||||
public function postFetch(SQL $sql, array $row) {
|
||||
parent::postFetch($sql, $row);
|
||||
$this->authenticated = $_SESSION["2faAuthenticated"] ?? false;
|
||||
$this->readData($row["data"]);
|
||||
}
|
||||
|
||||
@ -63,4 +71,14 @@ abstract class TwoFactorToken extends DatabaseEntity {
|
||||
$this->confirmed = true;
|
||||
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;
|
||||
|
||||
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\NullIf;
|
||||
use Core\Driver\SQL\SQL;
|
||||
use Core\Objects\DatabaseEntity\Attribute\DefaultValue;
|
||||
use Core\Objects\DatabaseEntity\Attribute\MaxLength;
|
||||
@ -141,4 +145,13 @@ class User extends DatabaseEntity {
|
||||
public function getDisplayName(): string {
|
||||
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 {
|
||||
|
||||
use Core\Objects\TwoFactor\CBORDecoder;
|
||||
use CBORDecoder;
|
||||
|
||||
private string $format;
|
||||
private array $statement;
|
||||
|
@ -3,70 +3,92 @@
|
||||
namespace Core\Objects\TwoFactor;
|
||||
|
||||
use Core\Driver\SQL\SQL;
|
||||
use Core\Objects\DatabaseEntity\Attribute\Transient;
|
||||
use Cose\Algorithm\Signature\ECDSA\ECSignature;
|
||||
use Core\Objects\DatabaseEntity\TwoFactorToken;
|
||||
use Cose\Key\Key;
|
||||
|
||||
class KeyBasedTwoFactorToken extends TwoFactorToken {
|
||||
|
||||
const TYPE = "fido";
|
||||
|
||||
#[Transient]
|
||||
private ?string $challenge;
|
||||
private ?string $credentialId;
|
||||
|
||||
#[Transient]
|
||||
private ?string $credentialID;
|
||||
|
||||
#[Transient]
|
||||
private ?PublicKey $publicKey;
|
||||
|
||||
public function __construct(string $challenge) {
|
||||
private function __construct() {
|
||||
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) {
|
||||
if (!$this->isConfirmed()) {
|
||||
$this->challenge = base64_decode($data);
|
||||
$this->credentialId = null;
|
||||
$this->challenge = $data;
|
||||
$this->credentialID = null;
|
||||
$this->publicKey = null;
|
||||
} else {
|
||||
$jsonData = json_decode($data, true);
|
||||
$this->challenge = base64_decode($_SESSION["challenge"] ?? "");
|
||||
$this->credentialId = base64_decode($jsonData["credentialID"]);
|
||||
$this->challenge = $_SESSION["challenge"] ?? "";
|
||||
$this->credentialID = base64_decode($jsonData["credentialID"]);
|
||||
$this->publicKey = PublicKey::fromJson($jsonData["publicKey"]);
|
||||
}
|
||||
}
|
||||
|
||||
public function getData(): string {
|
||||
if ($this->isConfirmed()) {
|
||||
return base64_encode($this->challenge);
|
||||
if (!$this->isConfirmed()) {
|
||||
return $this->challenge;
|
||||
} else {
|
||||
return json_encode([
|
||||
"credentialId" => $this->credentialId,
|
||||
"credentialID" => $this->credentialID,
|
||||
"publicKey" => $this->publicKey->jsonSerialize()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function confirmKeyBased(SQL $sql, string $credentialId, PublicKey $publicKey): bool {
|
||||
$this->credentialId = $credentialId;
|
||||
public function confirmKeyBased(SQL $sql, string $credentialID, PublicKey $publicKey): bool {
|
||||
$this->credentialID = $credentialID;
|
||||
$this->publicKey = $publicKey;
|
||||
return parent::confirm($sql);
|
||||
}
|
||||
|
||||
|
||||
public function getPublicKey(): ?PublicKey {
|
||||
return $this->publicKey;
|
||||
}
|
||||
|
||||
public function getCredentialId(): ?string {
|
||||
return $this->credentialId;
|
||||
return $this->credentialID;
|
||||
}
|
||||
|
||||
public function jsonSerialize(?array $propertyNames = null): array {
|
||||
$jsonData = parent::jsonSerialize();
|
||||
|
||||
if (!empty($this->challenge) && !$this->isAuthenticated() && in_array("challenge", $propertyNames)) {
|
||||
$jsonData["challenge"] = base64_encode($this->challenge);
|
||||
if (!$this->isAuthenticated()) {
|
||||
if (!empty($this->challenge) && ($propertyNames === null || in_array("challenge", $propertyNames))) {
|
||||
$jsonData["challenge"] = $this->challenge;
|
||||
}
|
||||
|
||||
if (!empty($this->credentialId) && in_array("credentialID", $propertyNames)) {
|
||||
$jsonData["credentialID"] = base64_encode($this->credentialId);
|
||||
if (!empty($this->credentialID) && ($propertyNames === null || in_array("credentialID", $propertyNames))) {
|
||||
$jsonData["credentialID"] = base64_encode($this->credentialID);
|
||||
}
|
||||
}
|
||||
|
||||
return $jsonData;
|
||||
|
@ -6,7 +6,7 @@ use Core\Objects\ApiObject;
|
||||
|
||||
class PublicKey extends ApiObject {
|
||||
|
||||
use Core\Objects\TwoFactor\CBORDecoder;
|
||||
use CBORDecoder;
|
||||
|
||||
private int $keyType;
|
||||
private int $usedAlgorithm;
|
||||
|
@ -7,11 +7,14 @@ use chillerlan\QRCode\QRCode;
|
||||
use chillerlan\QRCode\QROptions;
|
||||
use Core\Driver\SQL\SQL;
|
||||
use Core\Objects\Context;
|
||||
use Core\Objects\DatabaseEntity\Attribute\Visibility;
|
||||
use Core\Objects\DatabaseEntity\TwoFactorToken;
|
||||
|
||||
class TimeBasedTwoFactorToken extends TwoFactorToken {
|
||||
|
||||
const TYPE = "totp";
|
||||
|
||||
#[Visibility(Visibility::NONE)]
|
||||
private string $secret;
|
||||
|
||||
public function __construct(string $secret) {
|
||||
|
@ -229,7 +229,7 @@ function getClassPath($class, string $suffix = ".class"): string {
|
||||
$group = $pathParts[$pathCount - 2];
|
||||
if (strcasecmp($group, "Parameter") !== 0 && strcasecmp($group, "Traits") !== 0) {
|
||||
$pathParts = array_slice($pathParts, 0, $pathCount - 2);
|
||||
$pathParts[] = "${group}API";
|
||||
$pathParts[] = "{$group}API";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
34
cli.php
34
cli.php
@ -40,9 +40,16 @@ if (!$context->isCLI()) {
|
||||
}
|
||||
|
||||
$database = $context->getConfig()->getDatabase();
|
||||
if ($database !== null && $database->getProperty("isDocker", false) && !is_file("/.dockerenv")) {
|
||||
if (count($argv) < 3 || $argv[1] !== "db" || !in_array($argv[2], ["shell", "import", "export"])) {
|
||||
if ($database->getProperty("isDocker", false) && !is_file("/.dockerenv")) {
|
||||
if (function_exists("yaml_parse")) {
|
||||
$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"];
|
||||
$command = array_merge(["docker", "exec", "-it", $containerName, "php"], $argv);
|
||||
$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) {
|
||||
global $dockerYaml;
|
||||
$action = $argv[2] ?? "";
|
||||
|
||||
if ($action === "migrate") {
|
||||
@ -174,7 +182,6 @@ function handleDatabase(array $argv) {
|
||||
|
||||
$command = array_merge([$command_bin], $command_args);
|
||||
if ($config->getProperty("isDocker", false)) {
|
||||
$dockerYaml = yaml_parse(file_get_contents("./docker-compose.yml"));
|
||||
$containerName = $dockerYaml["services"]["db"]["container_name"];
|
||||
$command = array_merge(["docker", "exec", "-it", $containerName], $command);
|
||||
}
|
||||
@ -454,21 +461,24 @@ function onRoutes(array $argv) {
|
||||
$action = $argv[2] ?? "list";
|
||||
|
||||
if ($action === "list") {
|
||||
$req = new \Core\API\Routes\Fetch($context);
|
||||
$success = $req->execute();
|
||||
if (!$success) {
|
||||
_exit("Error fetching routes: " . $req->getLastError());
|
||||
$sql = $context->getSQL();
|
||||
$routes = \Core\Objects\DatabaseEntity\Route::findAll($sql);
|
||||
if ($routes === false || $routes === null) {
|
||||
_exit("Error fetching routes: " . $sql->getLastError());
|
||||
} else {
|
||||
$routes = $req->getResult()["routes"];
|
||||
$head = ["id", "pattern", "type", "target", "extra", "active", "exact"];
|
||||
|
||||
// strict boolean
|
||||
foreach ($routes as &$route) {
|
||||
$route["active"] = $route["active"] ? "true" : "false";
|
||||
$route["exact"] = $route["exact"] ? "true" : "false";
|
||||
$tableRows = [];
|
||||
foreach ($routes as $route) {
|
||||
$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") {
|
||||
if (count($argv) < 7) {
|
||||
|
19
img/icons/google_authenticator.svg
Normal file
19
img/icons/google_authenticator.svg
Normal file
@ -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
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();
|
||||
|
||||
function L(key) {
|
||||
function L(key, defaultTranslation=null) {
|
||||
|
||||
let entries = window.languageEntries || {};
|
||||
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.user = null;
|
||||
this.session = null;
|
||||
this.language = { id: 1, code: "en_US", shortCode: "en", name: "American English" };
|
||||
this.permissions = [];
|
||||
}
|
||||
|
||||
@ -80,24 +81,31 @@ export default class API {
|
||||
|
||||
/** UserAPI **/
|
||||
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() {
|
||||
let response = await fetch("/api/user/info");
|
||||
let data = await response.json();
|
||||
if (data) {
|
||||
this.loggedIn = data["loggedIn"];
|
||||
this.permissions = data["permissions"] ? data["permissions"].map(s => s.toLowerCase()) : [];
|
||||
let res = await this.apiCall("user/info");
|
||||
if (res.success) {
|
||||
this.loggedIn = res.loggedIn;
|
||||
this.language = res.language;
|
||||
this.permissions = (res.permissions || []).map(s => s.toLowerCase());
|
||||
if (this.loggedIn) {
|
||||
this.session = data["session"];
|
||||
this.user = data["user"];
|
||||
this.session = res.session;
|
||||
this.user = res.user;
|
||||
} else {
|
||||
this.session = null;
|
||||
this.user = null;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
return res;
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
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 **/
|
||||
async getStats() {
|
||||
return this.apiCall("stats");
|
||||
@ -204,7 +217,12 @@ export default class API {
|
||||
}
|
||||
|
||||
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) {
|
||||
@ -216,7 +234,6 @@ export default class API {
|
||||
}
|
||||
|
||||
/** ApiKeyAPI **/
|
||||
// API-Key API
|
||||
async getApiKeys(showActiveOnly = false) {
|
||||
return this.apiCall("apiKey/fetch", { showActiveOnly: showActiveOnly });
|
||||
}
|
||||
@ -228,4 +245,42 @@ export default class API {
|
||||
async revokeKey(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 {
|
||||
padding: 2px;
|
||||
padding: 5px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@ -11,10 +11,17 @@
|
||||
background-color: #bbb;
|
||||
}
|
||||
|
||||
.sortable {
|
||||
cursor: pointer;
|
||||
.data-table th > svg {
|
||||
vertical-align: middle;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.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 ArrowDownwardIcon from "@material-ui/icons/ArrowDownward";
|
||||
import React, {useCallback, useContext, useEffect, useState} from "react";
|
||||
import usePagination from "../hooks/pagination";
|
||||
import {parse} from "date-fns";
|
||||
import "./data-table.css";
|
||||
import {LocaleContext} from "../locale";
|
||||
import clsx from "clsx";
|
||||
import {Box} from "@mui/material";
|
||||
import {formatDate} from "../util";
|
||||
import {Box, IconButton} from "@mui/material";
|
||||
import {formatDateTime} from "../util";
|
||||
import UserLink from "security-lab/src/elements/user/userlink";
|
||||
import CachedIcon from "@material-ui/icons/Cached";
|
||||
|
||||
|
||||
export function DataTable(props) {
|
||||
|
||||
const { className, placeholder,
|
||||
columns, data, pagination,
|
||||
fetchData, onClick, onFilter,
|
||||
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 [data, setData] = useState(null);
|
||||
const [sortAscending, setSortAscending] = useState(["asc","ascending"].includes(defaultSortOrder?.toLowerCase));
|
||||
const [sortColumn, setSortColumn] = useState(defaultSortColumn || null);
|
||||
const pagination = usePagination();
|
||||
const sortable = props.hasOwnProperty("sortable") ? !!props.sortable : true;
|
||||
const onRowClick = onClick || (() => {});
|
||||
|
||||
const onFetchData = useCallback((force = false) => {
|
||||
if (doFetchData || force) {
|
||||
setFetchData(false);
|
||||
const orderBy = columns[sortColumn]?.field || null;
|
||||
const sortOrder = sortAscending ? "asc" : "desc";
|
||||
fetchData(pagination.getPage(), pagination.getPageSize(), orderBy, sortOrder).then(([data, dataPagination]) => {
|
||||
if (data) {
|
||||
setData(data);
|
||||
pagination.update(dataPagination);
|
||||
}
|
||||
});
|
||||
fetchData(pagination.getPage(), pagination.getPageSize(), orderBy, sortOrder);
|
||||
}
|
||||
}, [doFetchData, columns, sortColumn, sortAscending, pagination]);
|
||||
|
||||
// pagination changed?
|
||||
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);
|
||||
}, [pagination.data.pageSize, pagination.data.current]);
|
||||
|
||||
@ -69,13 +72,14 @@ export function DataTable(props) {
|
||||
}
|
||||
|
||||
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}
|
||||
onClick={() => onChangeSort(index, column) }>
|
||||
onClick={() => onChangeSort(index, column)}
|
||||
align={column.align}>
|
||||
{sortColumn === index ? (sortAscending ? <ArrowUpwardIcon /> : <ArrowDownwardIcon />): <></>}{column.renderHead(index)}
|
||||
</TableCell>);
|
||||
} else {
|
||||
headerRow.push(<TableCell key={"col-" + index}>
|
||||
headerRow.push(<TableCell key={"col-" + index} align={column.align}>
|
||||
{column.renderHead(index)}
|
||||
</TableCell>);
|
||||
}
|
||||
@ -83,14 +87,20 @@ export function DataTable(props) {
|
||||
|
||||
const numColumns = columns.length;
|
||||
let rows = [];
|
||||
if (data) {
|
||||
for (const [key, entry] of Object.entries(data)) {
|
||||
if (data && data?.length) {
|
||||
for (const [rowIndex, entry] of data.entries()) {
|
||||
let row = [];
|
||||
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) {
|
||||
rows.push(<TableRow key={"row-placeholder"}>
|
||||
@ -100,151 +110,13 @@ export function DataTable(props) {
|
||||
</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"}>
|
||||
<h3>
|
||||
<IconButton onClick={() => onFetchData(true)}>
|
||||
<CachedIcon/>
|
||||
</IconButton>
|
||||
{title}
|
||||
</h3>
|
||||
<Table className={clsx("data-table", className)} size="small" {...other}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
@ -260,17 +132,14 @@ export function DataTable(props) {
|
||||
}
|
||||
|
||||
export class DataColumn {
|
||||
constructor(label, field = null, sortable = true) {
|
||||
constructor(label, field = null, params = {}) {
|
||||
this.label = label;
|
||||
this.field = field;
|
||||
this.sortable = sortable;
|
||||
this.sortable = !params.hasOwnProperty("sortable") || !!params.sortable;
|
||||
this.align = params.align || "left";
|
||||
}
|
||||
|
||||
compare(a, b) {
|
||||
throw new Error("Not implemented: compare");
|
||||
}
|
||||
|
||||
renderData(L, entry) {
|
||||
renderData(L, entry, index) {
|
||||
return entry[this.field]
|
||||
}
|
||||
|
||||
@ -280,49 +149,88 @@ export class DataColumn {
|
||||
}
|
||||
|
||||
export class StringColumn extends DataColumn {
|
||||
constructor(label, field = null, sortable = true, caseSensitive = false) {
|
||||
super(label, field, sortable);
|
||||
this.caseSensitve = caseSensitive;
|
||||
}
|
||||
|
||||
compare(a, b) {
|
||||
if (this.caseSensitve) {
|
||||
return a.toString().localeCompare(b.toString());
|
||||
} else {
|
||||
return a.toString().toLowerCase().localeCompare(b.toString().toLowerCase());
|
||||
}
|
||||
constructor(label, field = null, params = {}) {
|
||||
super(label, field, params);
|
||||
}
|
||||
}
|
||||
|
||||
export class NumericColumn extends DataColumn {
|
||||
constructor(label, field = null, sortable = true) {
|
||||
super(label, field, sortable);
|
||||
constructor(label, field = null, params = {}) {
|
||||
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) {
|
||||
return a - b;
|
||||
renderData(L, entry, index) {
|
||||
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 {
|
||||
constructor(label, field = null, sortable = true, format = "YYYY-MM-dd HH:mm:ss") {
|
||||
super(label, field, sortable);
|
||||
this.format = format;
|
||||
constructor(label, field = null, params = {}) {
|
||||
super(label, field, params);
|
||||
this.precise = !!params.precise;
|
||||
}
|
||||
|
||||
compare(a, b) {
|
||||
if (typeof a === 'string') {
|
||||
a = parse(a, this.format, new Date()).getTime();
|
||||
renderData(L, entry, index) {
|
||||
let date = super.renderData(L, entry);
|
||||
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) {
|
||||
return <UserLink user={super.renderData(L, entry)}/>
|
||||
}
|
||||
}
|
||||
|
||||
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});
|
||||
}
|
||||
|
||||
setTotal(count) {
|
||||
this.setData({...this.data, total: count});
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.setData({current: 1, pageSize: 25, total: 0});
|
||||
}
|
||||
@ -37,6 +41,10 @@ class Pagination {
|
||||
}
|
||||
}
|
||||
|
||||
getParams() {
|
||||
return [this.data.current, this.data.pageSize];
|
||||
}
|
||||
|
||||
getTotal() {
|
||||
return this.data.total;
|
||||
}
|
||||
|
@ -62,16 +62,6 @@ function LocaleProvider(props) {
|
||||
}
|
||||
}, [entries]);
|
||||
|
||||
const toDateFns = () => {
|
||||
switch (currentLocale) {
|
||||
case 'de_DE':
|
||||
return dateFnsDE;
|
||||
case 'en_US':
|
||||
default:
|
||||
return dateFnsEN;
|
||||
}
|
||||
}
|
||||
|
||||
/** API HOOKS **/
|
||||
const setLanguage = useCallback(async (api, params) => {
|
||||
let res = await api.setLanguage(params);
|
||||
@ -96,8 +86,8 @@ function LocaleProvider(props) {
|
||||
|
||||
if (code === null) {
|
||||
code = currentLocale;
|
||||
if (code === null && api.loggedIn) {
|
||||
code = api.user.language.code;
|
||||
if (code === null && api.language) {
|
||||
code = api.language.code;
|
||||
}
|
||||
}
|
||||
|
||||
@ -135,11 +125,23 @@ function LocaleProvider(props) {
|
||||
}
|
||||
}, [currentLocale, getModule, dispatch]);
|
||||
|
||||
const toDateFns = useCallback(() => {
|
||||
switch (currentLocale) {
|
||||
case 'de_DE':
|
||||
return dateFnsDE;
|
||||
case 'en_US':
|
||||
default:
|
||||
return dateFnsEN;
|
||||
}
|
||||
}, [currentLocale]);
|
||||
|
||||
const ctx = {
|
||||
currentLocale: currentLocale,
|
||||
translate: translate,
|
||||
requestModules: requestModules,
|
||||
setLanguageByCode: setLanguageByCode,
|
||||
toDateFns: toDateFns,
|
||||
setCurrentLocale: setCurrentLocale,
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -59,7 +59,7 @@ const formatDate = (L, apiDate) => {
|
||||
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 (!isNaN(apiDate)) {
|
||||
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) => {
|
||||
|
@ -71,15 +71,24 @@ export default function LoginForm(props) {
|
||||
|
||||
const api = props.api;
|
||||
const classes = useStyles();
|
||||
|
||||
// inputs
|
||||
let [username, setUsername] = useState("");
|
||||
let [password, setPassword] = useState("");
|
||||
let [rememberMe, setRememberMe] = useState(true);
|
||||
let [isLoggingIn, setLoggingIn] = useState(false);
|
||||
let [emailConfirmed, setEmailConfirmed] = useState(null);
|
||||
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("");
|
||||
|
||||
const abortController = new AbortController();
|
||||
const abortSignal = abortController.signal;
|
||||
|
||||
// state
|
||||
let [isLoggingIn, setLoggingIn] = useState(false);
|
||||
let [loaded, setLoaded] = useState(false);
|
||||
|
||||
const {translate: L, currentLocale, requestModules} = useContext(LocaleContext);
|
||||
@ -103,13 +112,14 @@ export default function LoginForm(props) {
|
||||
setLoggingIn(true);
|
||||
removeParameter("success");
|
||||
api.login(username, password, rememberMe).then((res) => {
|
||||
set2FAState(0);
|
||||
let twoFactorToken = res.twoFactorToken || { };
|
||||
set2FAToken({ ...twoFactorToken, authenticated: false, step: 0, error: "" });
|
||||
setLoggingIn(false);
|
||||
setPassword("");
|
||||
if (!res.success) {
|
||||
setEmailConfirmed(res.emailConfirmed);
|
||||
setError(res.msg);
|
||||
} else {
|
||||
} else if (!twoFactorToken.type) {
|
||||
props.onLogin();
|
||||
}
|
||||
});
|
||||
@ -118,92 +128,88 @@ export default function LoginForm(props) {
|
||||
|
||||
const onSubmit2FA = useCallback(() => {
|
||||
setLoggingIn(true);
|
||||
props.onTotp2FA(tfaCode, (res) => {
|
||||
api.verifyTotp2FA(tfaCode).then((res) => {
|
||||
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(() => {
|
||||
abortController.abort();
|
||||
props.onLogout();
|
||||
}, [props]);
|
||||
set2FAToken({authenticated: false, step: 0, error: ""});
|
||||
}, [props, abortController]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!api.loggedIn || !api.user) {
|
||||
if (!api.loggedIn) {
|
||||
return;
|
||||
}
|
||||
|
||||
let twoFactor = api.user["2fa"];
|
||||
if (!twoFactor || !twoFactor.confirmed ||
|
||||
twoFactor.authenticated || twoFactor.type !== "fido") {
|
||||
if (!tfaToken || !tfaToken.confirmed || tfaToken.authenticated || tfaToken.type !== "fido") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (tfaState === 0) {
|
||||
set2FAState(1);
|
||||
set2FAError("");
|
||||
let step = tfaToken.step || 0;
|
||||
if (step !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
set2FAToken({ ...tfaToken, step: 1, error: "" });
|
||||
navigator.credentials.get({
|
||||
publicKey: {
|
||||
challenge: encodeText(window.atob(twoFactor.challenge)),
|
||||
challenge: encodeText(window.atob(tfaToken.challenge)),
|
||||
allowCredentials: [{
|
||||
id: encodeText(window.atob(twoFactor.credentialID)),
|
||||
id: encodeText(window.atob(tfaToken.credentialID)),
|
||||
type: "public-key",
|
||||
}],
|
||||
userVerification: "discouraged",
|
||||
},
|
||||
signal: abortSignal
|
||||
}).then((res) => {
|
||||
let credentialID = res.id;
|
||||
let clientDataJson = decodeText(res.response.clientDataJSON);
|
||||
let authData = window.btoa(decodeText(res.response.authenticatorData));
|
||||
let signature = window.btoa(decodeText(res.response.signature));
|
||||
props.onKey2FA(credentialID, clientDataJson, authData, signature, res => {
|
||||
api.verifyKey2FA(credentialID, clientDataJson, authData, signature).then((res) => {
|
||||
if (!res.success) {
|
||||
set2FAState(2);
|
||||
set2FAToken({ ...tfaToken, step: 2, error: res.msg });
|
||||
} else {
|
||||
props.onLogin();
|
||||
}
|
||||
});
|
||||
}).catch(e => {
|
||||
set2FAState(2);
|
||||
set2FAError(e.toString());
|
||||
set2FAToken({ ...tfaToken, step: 2, error: e.toString() });
|
||||
});
|
||||
}
|
||||
}, [api.loggedIn, api.user, tfaState, props]);
|
||||
}, [api.loggedIn, tfaToken, props.onLogin, props.onKey2FA, abortSignal]);
|
||||
|
||||
const createForm = () => {
|
||||
|
||||
// 2FA
|
||||
if (api.loggedIn && api.user["2fa"]) {
|
||||
if (api.loggedIn && tfaToken.type) {
|
||||
|
||||
if (tfaToken.type === "totp") {
|
||||
return <>
|
||||
<div>{L("account.2fa_title")}: {api.user["2fa"].type}</div>
|
||||
{ api.user["2fa"].type === "totp" ?
|
||||
<div>{L("account.2fa_title")}:</div>
|
||||
<TextField
|
||||
variant="outlined" margin="normal"
|
||||
id="code" label={L("account.6_digit_code")} name="code"
|
||||
autoComplete="code"
|
||||
variant={"outlined"} margin={"normal"}
|
||||
id={"code"} label={L("account.6_digit_code")} name={"code"}
|
||||
autoComplete={"code"}
|
||||
required fullWidth autoFocus
|
||||
value={tfaCode} onChange={(e) => set2FACode(e.target.value)}
|
||||
/> : <>
|
||||
{L("account.2fa_text")}
|
||||
<Box mt={2} textAlign={"center"}>
|
||||
{tfaState !== 2
|
||||
? <CircularProgress/>
|
||||
: <div className={classes.error2FA}>
|
||||
<div>{L("general.something_went_wrong")}:<br />{tfaError}</div>
|
||||
<Button onClick={() => set2FAState(0)}
|
||||
variant={"outlined"} color={"secondary"} size={"small"}>
|
||||
<ReplayIcon /> {L("general.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
</Box>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{
|
||||
error ? <Alert severity="error">{error}</Alert> : <></>
|
||||
tfaToken.error ? <Alert severity="error">{tfaToken.error}</Alert> : <></>
|
||||
}
|
||||
<Grid container spacing={2} className={classes.buttons2FA}>
|
||||
<Grid item xs={6}>
|
||||
<Button
|
||||
fullWidth variant="contained"
|
||||
color="inherit" size={"medium"}
|
||||
fullWidth variant={"contained"}
|
||||
color={"inherit"} size={"medium"}
|
||||
disabled={isLoggingIn}
|
||||
onClick={onCancel2FA}>
|
||||
{L("general.go_back")}
|
||||
@ -213,7 +219,7 @@ export default function LoginForm(props) {
|
||||
<Button
|
||||
type="submit" fullWidth variant="contained"
|
||||
color="primary" size={"medium"}
|
||||
disabled={isLoggingIn || api.user["2fa"].type !== "totp"}
|
||||
disabled={isLoggingIn || tfaToken.type !== "totp"}
|
||||
onClick={onSubmit2FA}>
|
||||
{isLoggingIn ?
|
||||
<>{L("general.submitting")}… <CircularProgress size={15}/></> :
|
||||
@ -223,6 +229,48 @@ export default function LoginForm(props) {
|
||||
</Grid>
|
||||
</Grid>
|
||||
</>
|
||||
} else if (tfaToken.type === "fido") {
|
||||
return <>
|
||||
<div>{L("account.2fa_title")}:</div>
|
||||
<br />
|
||||
{L("account.2fa_text")}
|
||||
<Box mt={2} textAlign={"center"}>
|
||||
{tfaToken.step !== 2
|
||||
? <CircularProgress/>
|
||||
: <div className={classes.error2FA}>
|
||||
<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 /> {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>
|
||||
</>
|
||||
}
|
||||
}
|
||||
|
||||
return <>
|
||||
|
Loading…
Reference in New Issue
Block a user