2.4.1: Settings GPG, Localization, CLI DB migrate, minor improvements
This commit is contained in:
parent
7920d3164d
commit
150e4eb195
@ -10,7 +10,6 @@ namespace Core\API {
|
|||||||
$this->loginRequired = true;
|
$this->loginRequired = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
namespace Core\API\GpgKey {
|
namespace Core\API\GpgKey {
|
||||||
@ -20,6 +19,7 @@ namespace Core\API\GpgKey {
|
|||||||
use Core\API\Parameter\Parameter;
|
use Core\API\Parameter\Parameter;
|
||||||
use Core\API\Parameter\StringType;
|
use Core\API\Parameter\StringType;
|
||||||
use Core\API\Template\Render;
|
use Core\API\Template\Render;
|
||||||
|
use Core\API\Traits\GpgKeyValidation;
|
||||||
use Core\Driver\SQL\Condition\Compare;
|
use Core\Driver\SQL\Condition\Compare;
|
||||||
use Core\Objects\Context;
|
use Core\Objects\Context;
|
||||||
use Core\Objects\DatabaseEntity\GpgKey;
|
use Core\Objects\DatabaseEntity\GpgKey;
|
||||||
@ -28,36 +28,16 @@ namespace Core\API\GpgKey {
|
|||||||
|
|
||||||
class Import extends GpgKeyAPI {
|
class Import extends GpgKeyAPI {
|
||||||
|
|
||||||
|
use GpgKeyValidation;
|
||||||
|
|
||||||
public function __construct(Context $context, bool $externalCall = false) {
|
public function __construct(Context $context, bool $externalCall = false) {
|
||||||
parent::__construct($context, $externalCall, [
|
parent::__construct($context, $externalCall, [
|
||||||
"pubkey" => new StringType("pubkey")
|
"publicKey" => new StringType("publicKey")
|
||||||
]);
|
]);
|
||||||
$this->loginRequired = true;
|
$this->loginRequired = true;
|
||||||
$this->forbidMethod("GET");
|
$this->forbidMethod("GET");
|
||||||
}
|
}
|
||||||
|
|
||||||
private function testKey(string $keyString) {
|
|
||||||
$res = GpgKey::getKeyInfo($keyString);
|
|
||||||
if (!$res["success"]) {
|
|
||||||
return $this->createError($res["error"] ?? $res["msg"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$keyData = $res["data"];
|
|
||||||
$keyType = $keyData["type"];
|
|
||||||
$expires = $keyData["expires"];
|
|
||||||
|
|
||||||
if ($keyType === "sec#") {
|
|
||||||
return self::createError("ATTENTION! It seems like you've imported a PGP PRIVATE KEY instead of a public key.
|
|
||||||
It is recommended to immediately revoke your private key and create a new key pair.");
|
|
||||||
} else if ($keyType !== "pub") {
|
|
||||||
return self::createError("Unknown key type: $keyType");
|
|
||||||
} else if (isInPast($expires)) {
|
|
||||||
return self::createError("It seems like the gpg key is already expired.");
|
|
||||||
} else {
|
|
||||||
return $keyData;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function _execute(): bool {
|
public function _execute(): bool {
|
||||||
|
|
||||||
$currentUser = $this->context->getUser();
|
$currentUser = $this->context->getUser();
|
||||||
@ -69,8 +49,7 @@ namespace Core\API\GpgKey {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// fix key first, enforce a newline after
|
// fix key first, enforce a newline after
|
||||||
$keyString = $this->getParam("pubkey");
|
$keyString = $this->formatKey($this->getParam("publicKey"));
|
||||||
$keyString = preg_replace("/(-{2,})\n([^\n])/", "$1\n\n$2", $keyString);
|
|
||||||
$keyData = $this->testKey($keyString);
|
$keyData = $this->testKey($keyString);
|
||||||
if ($keyData === false) {
|
if ($keyData === false) {
|
||||||
return false;
|
return false;
|
||||||
|
@ -215,38 +215,15 @@ abstract class Request {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->isMethodAllowed("GET") && $this->isMethodAllowed("POST")) {
|
$values = $_REQUEST;
|
||||||
$values = $_REQUEST;
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && in_array("application/json", explode(";", $_SERVER["CONTENT_TYPE"] ?? ""))) {
|
||||||
} else if ($this->isMethodAllowed("POST")) {
|
$jsonData = json_decode(file_get_contents('php://input'), true);
|
||||||
$values = $_POST;
|
if ($jsonData !== null) {
|
||||||
} else if ($this->isMethodAllowed("GET")) {
|
$values = array_merge($values, $jsonData);
|
||||||
$values = $_GET;
|
} else {
|
||||||
}
|
$this->lastError = 'Invalid request body.';
|
||||||
|
http_response_code(400);
|
||||||
if (in_array($_SERVER['REQUEST_METHOD'], ['POST', 'PUT', 'PATCH'])) {
|
return false;
|
||||||
$contentTypeData = explode(";", $_SERVER["CONTENT_TYPE"] ?? "");
|
|
||||||
$charset = "utf-8";
|
|
||||||
|
|
||||||
if ($contentTypeData[0] === "application/json") {
|
|
||||||
for ($i = 1; $i < count($contentTypeData); $i++) {
|
|
||||||
if (preg_match("/charset=(.*)/", $contentTypeData[$i], $match)) {
|
|
||||||
$charset = $match[1];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$body = file_get_contents('php://input');
|
|
||||||
if (strcasecmp($charset, "utf-8") !== 0) {
|
|
||||||
$body = iconv($charset, 'utf-8', $body);
|
|
||||||
}
|
|
||||||
|
|
||||||
$jsonData = json_decode($body, true);
|
|
||||||
if ($jsonData !== null) {
|
|
||||||
$values = array_merge($values, $jsonData);
|
|
||||||
} else {
|
|
||||||
$this->lastError = "Invalid request body.";
|
|
||||||
http_response_code(400);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -362,7 +339,8 @@ abstract class Request {
|
|||||||
$obj = $this->params;
|
$obj = $this->params;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $obj[$name]?->value;
|
// I don't know why phpstorm
|
||||||
|
return (isset($obj[$name]) ? $obj[$name]->value : NULL);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isMethodAllowed(string $method): bool {
|
public function isMethodAllowed(string $method): bool {
|
||||||
|
@ -19,6 +19,7 @@ namespace Core\API {
|
|||||||
// API parameters should be more configurable, e.g. allow regexes, min/max values for numbers, etc.
|
// API parameters should be more configurable, e.g. allow regexes, min/max values for numbers, etc.
|
||||||
$this->predefinedKeys = [
|
$this->predefinedKeys = [
|
||||||
"allowed_extensions" => new ArrayType("allowed_extensions", Parameter::TYPE_STRING),
|
"allowed_extensions" => new ArrayType("allowed_extensions", Parameter::TYPE_STRING),
|
||||||
|
"mail_contact" => new Parameter("mail_contact", Parameter::TYPE_EMAIL, true, ""),
|
||||||
"trusted_domains" => new ArrayType("trusted_domains", Parameter::TYPE_STRING),
|
"trusted_domains" => new ArrayType("trusted_domains", Parameter::TYPE_STRING),
|
||||||
"user_registration_enabled" => new Parameter("user_registration_enabled", Parameter::TYPE_BOOLEAN),
|
"user_registration_enabled" => new Parameter("user_registration_enabled", Parameter::TYPE_BOOLEAN),
|
||||||
"captcha_provider" => new StringType("captcha_provider", -1, true, "disabled", CaptchaProvider::PROVIDERS),
|
"captcha_provider" => new StringType("captcha_provider", -1, true, "disabled", CaptchaProvider::PROVIDERS),
|
||||||
@ -38,29 +39,41 @@ namespace Core\API\Settings {
|
|||||||
use Core\API\Parameter\RegexType;
|
use Core\API\Parameter\RegexType;
|
||||||
use Core\API\Parameter\StringType;
|
use Core\API\Parameter\StringType;
|
||||||
use Core\API\SettingsAPI;
|
use Core\API\SettingsAPI;
|
||||||
|
use Core\API\Traits\GpgKeyValidation;
|
||||||
use Core\Configuration\Settings;
|
use Core\Configuration\Settings;
|
||||||
use Core\Driver\SQL\Column\Column;
|
use Core\Driver\SQL\Column\Column;
|
||||||
use Core\Driver\SQL\Condition\CondBool;
|
use Core\Driver\SQL\Condition\CondBool;
|
||||||
use Core\Driver\SQL\Condition\CondIn;
|
use Core\Driver\SQL\Condition\CondIn;
|
||||||
use Core\Driver\SQL\Strategy\UpdateStrategy;
|
use Core\Driver\SQL\Strategy\UpdateStrategy;
|
||||||
use Core\Objects\Context;
|
use Core\Objects\Context;
|
||||||
|
use Core\Objects\DatabaseEntity\GpgKey;
|
||||||
use Core\Objects\DatabaseEntity\Group;
|
use Core\Objects\DatabaseEntity\Group;
|
||||||
|
|
||||||
class Get extends SettingsAPI {
|
class Get extends SettingsAPI {
|
||||||
|
|
||||||
|
private ?GpgKey $contactGpgKey;
|
||||||
|
|
||||||
public function __construct(Context $context, bool $externalCall = false) {
|
public function __construct(Context $context, bool $externalCall = false) {
|
||||||
parent::__construct($context, $externalCall, array(
|
parent::__construct($context, $externalCall, array(
|
||||||
'key' => new StringType('key', -1, true, NULL)
|
'key' => new StringType('key', -1, true, NULL)
|
||||||
));
|
));
|
||||||
|
$this->contactGpgKey = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function _execute(): bool {
|
public function _execute(): bool {
|
||||||
$key = $this->getParam("key");
|
$key = $this->getParam("key");
|
||||||
$sql = $this->context->getSQL();
|
$sql = $this->context->getSQL();
|
||||||
|
$siteSettings = $this->context->getSettings();
|
||||||
|
|
||||||
$settings = Settings::getAll($sql, $key, $this->isExternalCall());
|
$settings = Settings::getAll($sql, $key, $this->isExternalCall());
|
||||||
if ($settings !== null) {
|
if ($settings !== null) {
|
||||||
$this->result["settings"] = $settings;
|
$this->result["settings"] = $settings;
|
||||||
|
|
||||||
|
// TODO: improve this custom key
|
||||||
|
$gpgKeyId = $this->result["settings"]["mail_contact_gpg_key_id"] ?? null;
|
||||||
|
$this->contactGpgKey = $gpgKeyId === null ? null : GpgKey::find($sql, $gpgKeyId);
|
||||||
|
unset($this->result["settings"]["mail_contact_gpg_key_id"]);
|
||||||
|
$this->result["settings"]["mail_contact_gpg_key"] = $this->contactGpgKey?->jsonSerialize();
|
||||||
} else {
|
} else {
|
||||||
return $this->createError("Error fetching settings: " . $sql->getLastError());
|
return $this->createError("Error fetching settings: " . $sql->getLastError());
|
||||||
}
|
}
|
||||||
@ -68,6 +81,10 @@ namespace Core\API\Settings {
|
|||||||
return $this->success;
|
return $this->success;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getContactGpgKey(): ?GpgKey {
|
||||||
|
return $this->contactGpgKey;
|
||||||
|
}
|
||||||
|
|
||||||
public static function getDescription(): string {
|
public static function getDescription(): string {
|
||||||
return "Allows users to fetch site settings";
|
return "Allows users to fetch site settings";
|
||||||
}
|
}
|
||||||
@ -138,7 +155,6 @@ namespace Core\API\Settings {
|
|||||||
["value" => new Column("value")])
|
["value" => new Column("value")])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
$this->success = ($query->execute() !== FALSE);
|
$this->success = ($query->execute() !== FALSE);
|
||||||
$this->lastError = $sql->getLastError();
|
$this->lastError = $sql->getLastError();
|
||||||
|
|
||||||
@ -188,4 +204,90 @@ namespace Core\API\Settings {
|
|||||||
return [Group::ADMIN];
|
return [Group::ADMIN];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ImportGPG extends SettingsAPI {
|
||||||
|
|
||||||
|
use GpgKeyValidation;
|
||||||
|
|
||||||
|
public function __construct(Context $context, bool $externalCall = false) {
|
||||||
|
parent::__construct($context, $externalCall, [
|
||||||
|
"publicKey" => new StringType("publicKey")
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->forbidMethod("GET");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function _execute(): bool {
|
||||||
|
|
||||||
|
$sql = $this->context->getSQL();
|
||||||
|
|
||||||
|
// fix key first, enforce a newline after
|
||||||
|
$keyString = $this->formatKey($this->getParam("publicKey"));
|
||||||
|
$keyData = $this->testKey($keyString, null);
|
||||||
|
if ($keyData === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$res = GpgKey::importKey($keyString);
|
||||||
|
if (!$res["success"]) {
|
||||||
|
return $this->createError($res["error"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// we will auto-confirm this key
|
||||||
|
$sql = $this->context->getSQL();
|
||||||
|
$gpgKey = new GpgKey($keyData["fingerprint"], $keyData["algorithm"], $keyData["expires"], true);
|
||||||
|
if (!$gpgKey->save($sql)) {
|
||||||
|
return $this->createError("Error creating gpg key: " . $sql->getLastError());
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->success = $sql->insert("Settings", ["name", "value", "private", "readonly"])
|
||||||
|
->addRow("mail_contact_gpg_key_id", $gpgKey->getId(), false, true)
|
||||||
|
->onDuplicateKeyStrategy(new UpdateStrategy(
|
||||||
|
["name"],
|
||||||
|
["value" => new Column("value")])
|
||||||
|
)->execute() !== false;
|
||||||
|
|
||||||
|
$this->lastError = $sql->getLastError();
|
||||||
|
$this->result["gpgKey"] = $gpgKey->jsonSerialize();
|
||||||
|
return $this->success;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getDescription(): string {
|
||||||
|
return "Allows administrators to import a GPG-key to use it as a contact key.";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getDefaultPermittedGroups(): array {
|
||||||
|
return [Group::ADMIN];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RemoveGPG extends SettingsAPI {
|
||||||
|
public function __construct(Context $context, bool $externalCall = false) {
|
||||||
|
parent::__construct($context, $externalCall);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function _execute(): bool {
|
||||||
|
$sql = $this->context->getSQL();
|
||||||
|
$settings = $this->context->getSettings();
|
||||||
|
$gpgKey = $settings->getContactGPGKey();
|
||||||
|
if ($gpgKey === null) {
|
||||||
|
return $this->createError("No GPG-Key configured yet");
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->success = $sql->update("Settings")
|
||||||
|
->set("value", NULL)
|
||||||
|
->whereEq("name", "mail_contact_gpg_key_id")
|
||||||
|
->execute() !== false;
|
||||||
|
$this->lastError = $sql->getLastError();
|
||||||
|
return $this->success;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getDescription(): string {
|
||||||
|
return "Allows administrators to remove the GPG-key used as a contact key.";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getDefaultPermittedGroups(): array {
|
||||||
|
return [Group::ADMIN];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
34
Core/API/Traits/GpgKeyValidation.trait.php
Normal file
34
Core/API/Traits/GpgKeyValidation.trait.php
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Core\API\Traits;
|
||||||
|
|
||||||
|
use Core\Objects\DatabaseEntity\GpgKey;
|
||||||
|
|
||||||
|
trait GpgKeyValidation {
|
||||||
|
|
||||||
|
function testKey(string $keyString, ?string $expectedType = "pub") {
|
||||||
|
$res = GpgKey::getKeyInfo($keyString);
|
||||||
|
if (!$res["success"]) {
|
||||||
|
return $this->createError($res["error"] ?? $res["msg"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$keyData = $res["data"];
|
||||||
|
$keyType = $keyData["type"];
|
||||||
|
$expires = $keyData["expires"];
|
||||||
|
|
||||||
|
if ($expectedType === "pub" && $keyType === "sec#") {
|
||||||
|
return $this->createError("ATTENTION! It seems like you've imported a PGP PRIVATE KEY instead of a public key.
|
||||||
|
It is recommended to immediately revoke your private key and create a new key pair.");
|
||||||
|
} else if ($expectedType !== null && $keyType !== $expectedType) {
|
||||||
|
return $this->createError("Key has unexpected type: $keyType, expected: $expectedType");
|
||||||
|
} else if (isInPast($expires)) {
|
||||||
|
return $this->createError("It seems like the gpg key is already expired.");
|
||||||
|
} else {
|
||||||
|
return $keyData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatKey(string $keyString): string {
|
||||||
|
return preg_replace("/(-{2,})\n([^\n])/", "$1\n\n$2", $keyString);
|
||||||
|
}
|
||||||
|
}
|
@ -1008,7 +1008,15 @@ namespace Core\API\User {
|
|||||||
} else {
|
} else {
|
||||||
$this->success = ($user->delete($sql) !== FALSE);
|
$this->success = ($user->delete($sql) !== FALSE);
|
||||||
$this->lastError = $sql->getLastError();
|
$this->lastError = $sql->getLastError();
|
||||||
|
$this->logger->info(sprintf(
|
||||||
|
"User '%s' (id=%d) deleted by %s",
|
||||||
|
$user->getDisplayName(),
|
||||||
|
$id,
|
||||||
|
$this->logUserId())
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
$this->lastError = $sql->getLastError();
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->success;
|
return $this->success;
|
||||||
|
25
Core/Configuration/Patch/2024_05_11-Settings-GPG.php
Normal file
25
Core/Configuration/Patch/2024_05_11-Settings-GPG.php
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Core\Driver\SQL\Column\Column;
|
||||||
|
use Core\Driver\SQL\Strategy\UpdateStrategy;
|
||||||
|
use Core\Objects\DatabaseEntity\Group;
|
||||||
|
|
||||||
|
$queries[] = $sql->insert("Settings", ["name", "value", "private", "readonly"])
|
||||||
|
->onDuplicateKeyStrategy(new UpdateStrategy(
|
||||||
|
["name"],
|
||||||
|
["name" => new Column("name")])
|
||||||
|
)
|
||||||
|
->addRow("mail_contact_gpg_key_id", null, false, true)
|
||||||
|
->addRow("mail_contact", "''", false, false);
|
||||||
|
|
||||||
|
$queries[] = $sql->insert("ApiPermission", ["method", "groups", "description", "is_core"])
|
||||||
|
->onDuplicateKeyStrategy(new UpdateStrategy(
|
||||||
|
["method"],
|
||||||
|
["method" => new Column("method")])
|
||||||
|
)
|
||||||
|
->addRow("settings/importGPG",
|
||||||
|
json_encode(\Core\API\Settings\ImportGPG::getDefaultPermittedGroups()),
|
||||||
|
\Core\API\Settings\ImportGPG::getDescription(), true)
|
||||||
|
->addRow("settings/removeGPG",
|
||||||
|
json_encode(\Core\API\Settings\RemoveGPG::getDefaultPermittedGroups()),
|
||||||
|
\Core\API\Settings\RemoveGPG::getDescription(), true);
|
@ -17,6 +17,7 @@ use Core\Objects\Captcha\GoogleRecaptchaProvider;
|
|||||||
use Core\Objects\Captcha\HCaptchaProvider;
|
use Core\Objects\Captcha\HCaptchaProvider;
|
||||||
use Core\Objects\ConnectionData;
|
use Core\Objects\ConnectionData;
|
||||||
use Core\Objects\Context;
|
use Core\Objects\Context;
|
||||||
|
use Core\Objects\DatabaseEntity\GpgKey;
|
||||||
|
|
||||||
class Settings {
|
class Settings {
|
||||||
|
|
||||||
@ -25,6 +26,11 @@ class Settings {
|
|||||||
|
|
||||||
// general settings
|
// general settings
|
||||||
private string $siteName;
|
private string $siteName;
|
||||||
|
|
||||||
|
private string $contactMail;
|
||||||
|
|
||||||
|
private ?GpgKey $contactGpgKey;
|
||||||
|
|
||||||
private string $baseUrl;
|
private string $baseUrl;
|
||||||
private array $trustedDomains;
|
private array $trustedDomains;
|
||||||
private bool $registrationAllowed;
|
private bool $registrationAllowed;
|
||||||
@ -101,6 +107,8 @@ class Settings {
|
|||||||
|
|
||||||
// General
|
// General
|
||||||
$settings->siteName = "WebBase";
|
$settings->siteName = "WebBase";
|
||||||
|
$settings->contactMail = "webmaster@$hostname";
|
||||||
|
$settings->contactGpgKey = null;
|
||||||
$settings->baseUrl = "$protocol://$hostname";
|
$settings->baseUrl = "$protocol://$hostname";
|
||||||
$settings->trustedDomains = [$hostname];
|
$settings->trustedDomains = [$hostname];
|
||||||
$settings->allowedExtensions = ['png', 'jpg', 'jpeg', 'gif', 'htm', 'html'];
|
$settings->allowedExtensions = ['png', 'jpg', 'jpeg', 'gif', 'htm', 'html'];
|
||||||
@ -137,13 +145,15 @@ class Settings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function loadFromDatabase(Context $context): bool {
|
public function loadFromDatabase(Context $context): bool {
|
||||||
$this->logger = new Logger("Settings", $context->getSQL());
|
$sql = $context->getSQL();
|
||||||
|
$this->logger = new Logger("Settings", $sql);
|
||||||
$req = new \Core\API\Settings\Get($context);
|
$req = new \Core\API\Settings\Get($context);
|
||||||
$success = $req->execute();
|
$success = $req->execute();
|
||||||
|
|
||||||
if ($success) {
|
if ($success) {
|
||||||
$result = $req->getResult()["settings"];
|
$result = $req->getResult()["settings"];
|
||||||
$this->siteName = $result["site_name"] ?? $this->siteName;
|
$this->siteName = $result["site_name"] ?? $this->siteName;
|
||||||
|
$this->contactMail = $result["mail_contact"] ?? $this->contactMail;
|
||||||
$this->baseUrl = $result["base_url"] ?? $this->baseUrl;
|
$this->baseUrl = $result["base_url"] ?? $this->baseUrl;
|
||||||
$this->registrationAllowed = $result["user_registration_enabled"] ?? $this->registrationAllowed;
|
$this->registrationAllowed = $result["user_registration_enabled"] ?? $this->registrationAllowed;
|
||||||
$this->installationComplete = $result["installation_completed"] ?? $this->installationComplete;
|
$this->installationComplete = $result["installation_completed"] ?? $this->installationComplete;
|
||||||
@ -162,13 +172,18 @@ class Settings {
|
|||||||
$this->redisPort = $result["redis_port"] ?? $this->redisPort;
|
$this->redisPort = $result["redis_port"] ?? $this->redisPort;
|
||||||
$this->redisPassword = $result["redis_password"] ?? $this->redisPassword;
|
$this->redisPassword = $result["redis_password"] ?? $this->redisPassword;
|
||||||
date_default_timezone_set($this->timeZone);
|
date_default_timezone_set($this->timeZone);
|
||||||
|
|
||||||
|
$this->contactGpgKey = $req->getContactGpgKey();
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function addRows(Insert $query): void {
|
public function addRows(Insert $query): void {
|
||||||
|
// ["name", "value", "private", "readonly"]
|
||||||
$query->addRow("site_name", json_encode($this->siteName), false, false)
|
$query->addRow("site_name", json_encode($this->siteName), false, false)
|
||||||
|
->addRow("mail_contact", json_encode($this->contactMail), false, false)
|
||||||
|
->addRow("mail_contact_gpg_key_id", json_encode($this->contactGpgKey?->getId()), false, true)
|
||||||
->addRow("base_url", json_encode($this->baseUrl), false, false)
|
->addRow("base_url", json_encode($this->baseUrl), false, false)
|
||||||
->addRow("trusted_domains", json_encode($this->trustedDomains), false, false)
|
->addRow("trusted_domains", json_encode($this->trustedDomains), false, false)
|
||||||
->addRow("user_registration_enabled", json_encode($this->registrationAllowed), false, false)
|
->addRow("user_registration_enabled", json_encode($this->registrationAllowed), false, false)
|
||||||
@ -196,6 +211,14 @@ class Settings {
|
|||||||
return $this->siteName;
|
return $this->siteName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getContactMail(): string {
|
||||||
|
return $this->contactMail;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getContactGPGKey(): ?GpgKey {
|
||||||
|
return $this->contactGpgKey;
|
||||||
|
}
|
||||||
|
|
||||||
public function getTimeZone(): string {
|
public function getTimeZone(): string {
|
||||||
return $this->timeZone;
|
return $this->timeZone;
|
||||||
}
|
}
|
||||||
|
@ -212,7 +212,7 @@ namespace Documents\Install {
|
|||||||
$step = self::FINISH_INSTALLATION;
|
$step = self::FINISH_INSTALLATION;
|
||||||
|
|
||||||
$req = new \Core\API\Settings\Set($context);
|
$req = new \Core\API\Settings\Set($context);
|
||||||
$success = $req->execute(["settings" => ["installation_completed" => "1"]]);
|
$success = $req->execute(["settings" => ["installation_completed" => true]]);
|
||||||
if (!$success) {
|
if (!$success) {
|
||||||
$this->errorString = $req->getLastError();
|
$this->errorString = $req->getLastError();
|
||||||
}
|
}
|
||||||
@ -633,12 +633,12 @@ namespace Documents\Install {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$items[] = html_tag("li", $attr, [
|
$items[] = html_tag("li", $attr, [
|
||||||
html_tag("div", [], [
|
html_tag("div", [], [
|
||||||
html_tag("h6", ["class" => "my-0"], $title),
|
html_tag("h6", ["class" => "my-0"], $title),
|
||||||
html_tag("small", ["class" => "text-$statusColor"], $statusText),
|
html_tag("small", ["class" => "text-$statusColor"], $statusText),
|
||||||
], false),
|
], false),
|
||||||
html_tag("span", ["class" => "text-$statusColor"], $statusIcon, false)
|
html_tag("span", ["class" => "text-$statusColor"], $statusIcon, false)
|
||||||
], false);
|
], false);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $items;
|
return $items;
|
||||||
@ -956,7 +956,7 @@ namespace Documents\Install {
|
|||||||
html_tag("div", ["class" => "col-md-4 order-md-2 mb-4"], [
|
html_tag("div", ["class" => "col-md-4 order-md-2 mb-4"], [
|
||||||
html_tag("h4", ["class" => "d-flex justify-content-between align-items-center mb-3"],
|
html_tag("h4", ["class" => "d-flex justify-content-between align-items-center mb-3"],
|
||||||
html_tag("span", ["class" => "text-muted"], "Progress"),
|
html_tag("span", ["class" => "text-muted"], "Progress"),
|
||||||
false
|
false
|
||||||
),
|
),
|
||||||
html_tag("ul", ["class" => "list-group mb-3"], $progressSidebar, false)
|
html_tag("ul", ["class" => "list-group mb-3"], $progressSidebar, false)
|
||||||
], false),
|
], false),
|
||||||
@ -968,7 +968,7 @@ namespace Documents\Install {
|
|||||||
], false)
|
], false)
|
||||||
|
|
||||||
], false),
|
], false),
|
||||||
|
|
||||||
], false),
|
], false),
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
|
@ -28,7 +28,7 @@ class Security extends Document {
|
|||||||
|
|
||||||
$sql = $this->getContext()->getSQL();
|
$sql = $this->getContext()->getSQL();
|
||||||
$settings = $this->getSettings();
|
$settings = $this->getSettings();
|
||||||
$mailSettings = Settings::getAll($sql, "^mail_");
|
$gpgKey = $settings->getContactGPGKey();
|
||||||
|
|
||||||
if ($activeRoute->getPattern() === "/.well-known/security.txt") {
|
if ($activeRoute->getPattern() === "/.well-known/security.txt") {
|
||||||
|
|
||||||
@ -39,7 +39,7 @@ class Security extends Document {
|
|||||||
|
|
||||||
$expires = (new \DateTime())->setTime(0, 0, 0)->modify("+3 months");
|
$expires = (new \DateTime())->setTime(0, 0, 0)->modify("+3 months");
|
||||||
$baseUrl = $settings->getBaseUrl();
|
$baseUrl = $settings->getBaseUrl();
|
||||||
$gpgKey = null;
|
// $gpgKey = null;
|
||||||
|
|
||||||
$lines = [
|
$lines = [
|
||||||
"# This project is based on the open-source framework hosted on https://github.com/rhergenreder/web-base",
|
"# This project is based on the open-source framework hosted on https://github.com/rhergenreder/web-base",
|
||||||
@ -53,19 +53,16 @@ class Security extends Document {
|
|||||||
"",
|
"",
|
||||||
];
|
];
|
||||||
|
|
||||||
if (isset($mailSettings["mail_contact"])) {
|
$contactAddress = $settings->getContactMail();
|
||||||
$lines[] = "Contact: " . $mailSettings["mail_contact"];
|
if (!empty($contactAddress)) {
|
||||||
|
$lines[] = "Contact: " . $contactAddress;
|
||||||
|
}
|
||||||
|
|
||||||
if (isset($mailSettings["mail_contact_gpg_key_id"])) {
|
if ($gpgKey !== null) {
|
||||||
$gpgKey = GpgKey::find($sql, $mailSettings["mail_contact_gpg_key_id"]);
|
$lines[] = "Encryption: $baseUrl/.well-known/gpg-key.txt";
|
||||||
if ($gpgKey) {
|
|
||||||
$lines[] = "Encryption: $baseUrl/.well-known/gpg-key.txt";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$code = implode("\n", $lines);
|
$code = implode("\n", $lines);
|
||||||
|
|
||||||
if ($gpgKey !== null) {
|
if ($gpgKey !== null) {
|
||||||
$res = GpgKey::sign($code, $gpgKey->getFingerprint());
|
$res = GpgKey::sign($code, $gpgKey->getFingerprint());
|
||||||
if ($res["success"]) {
|
if ($res["success"]) {
|
||||||
@ -75,17 +72,14 @@ class Security extends Document {
|
|||||||
|
|
||||||
return $code;
|
return $code;
|
||||||
} else if ($activeRoute->getPattern() === "/.well-known/gpg-key.txt") {
|
} else if ($activeRoute->getPattern() === "/.well-known/gpg-key.txt") {
|
||||||
|
if ($gpgKey !== null) {
|
||||||
if (isset($mailSettings["mail_contact_gpg_key_id"])) {
|
$res = $gpgKey->_export(true);
|
||||||
$gpgKey = GpgKey::find($sql, $mailSettings["mail_contact_gpg_key_id"]);
|
if ($res["success"]) {
|
||||||
if ($gpgKey !== null) {
|
|
||||||
header("Content-Type: text/plain");
|
header("Content-Type: text/plain");
|
||||||
$res = $gpgKey->_export(true);
|
return $res["data"];
|
||||||
if ($res["success"]) {
|
} else {
|
||||||
return $res["data"];
|
http_response_code(500);
|
||||||
} else {
|
return "Error exporting public key: " . $res["msg"];
|
||||||
return "Error exporting public key: " . $res["msg"];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
http_response_code(412);
|
http_response_code(412);
|
||||||
|
@ -100,7 +100,7 @@ class TemplateDocument extends Document {
|
|||||||
"query" => $urlParts["query"] ?? "",
|
"query" => $urlParts["query"] ?? "",
|
||||||
"fragment" => $urlParts["fragment"] ?? ""
|
"fragment" => $urlParts["fragment"] ?? ""
|
||||||
],
|
],
|
||||||
"lastModified" => date(L('Y-m-d H:i:s'), @filemtime(self::getTemplatePath($name))),
|
"lastModified" => date(L('general.date_time_format'), @filemtime(self::getTemplatePath($name))),
|
||||||
"registrationEnabled" => $settings->isRegistrationAllowed(),
|
"registrationEnabled" => $settings->isRegistrationAllowed(),
|
||||||
"title" => $this->title,
|
"title" => $this->title,
|
||||||
"captcha" => [
|
"captcha" => [
|
||||||
|
@ -91,6 +91,11 @@ return [
|
|||||||
"add_group_member_title" => "Mitglied hinzufügen",
|
"add_group_member_title" => "Mitglied hinzufügen",
|
||||||
"add_group_member_text" => "Einen Benutzer suchen um ihn der Gruppe hinzuzufügen",
|
"add_group_member_text" => "Einen Benutzer suchen um ihn der Gruppe hinzuzufügen",
|
||||||
"edit_profile" => "Profil bearbeiten",
|
"edit_profile" => "Profil bearbeiten",
|
||||||
|
"delete_user_error" => "Fehler beim Löschen des Benutzers",
|
||||||
|
"delete_user_title" => "Benutzer löschen",
|
||||||
|
"delete_user_text" => "Möchten Sie wirklich diesen Benutzer löschen? Dies kann nicht rückgängig gemacht werden!",
|
||||||
|
"error_reading_file" => "Fehler beim Lesen der Datei",
|
||||||
|
"invalid_gpg_key" => "Die ausgewählte Datei ist kein GPG-Public Key im ASCII-Format",
|
||||||
|
|
||||||
# GPG Key
|
# GPG Key
|
||||||
"gpg_key_placeholder_text" => "GPG-Key im ASCII format reinziehen oder einfügen...",
|
"gpg_key_placeholder_text" => "GPG-Key im ASCII format reinziehen oder einfügen...",
|
||||||
|
@ -17,6 +17,7 @@ return [
|
|||||||
"available_groups" => "verfügbare Gruppen",
|
"available_groups" => "verfügbare Gruppen",
|
||||||
"routes_defined" => "Routen definiert",
|
"routes_defined" => "Routen definiert",
|
||||||
"error_count" => "Fehler in den letzten 48 Stunden",
|
"error_count" => "Fehler in den letzten 48 Stunden",
|
||||||
|
"more_info" => "Mehr Infos",
|
||||||
|
|
||||||
# Dialogs
|
# Dialogs
|
||||||
"fetch_stats_error" => "Fehler beim Holen der Stats",
|
"fetch_stats_error" => "Fehler beim Holen der Stats",
|
||||||
|
@ -59,6 +59,7 @@ return [
|
|||||||
"choose_file" => "Datei auswählen",
|
"choose_file" => "Datei auswählen",
|
||||||
"download" => "Herunterladen",
|
"download" => "Herunterladen",
|
||||||
"download_all" => "Alles Herunterladen",
|
"download_all" => "Alles Herunterladen",
|
||||||
|
"upload_file" => "Datei hochladen",
|
||||||
"upload" => "Hochladen",
|
"upload" => "Hochladen",
|
||||||
"uploading" => "Lade hoch",
|
"uploading" => "Lade hoch",
|
||||||
"overwrite" => "Überschreiben",
|
"overwrite" => "Überschreiben",
|
||||||
|
@ -24,11 +24,14 @@ return [
|
|||||||
|
|
||||||
# general settings
|
# general settings
|
||||||
"site_name" => "Seitenname",
|
"site_name" => "Seitenname",
|
||||||
|
"mail_contact" => "Kontakt E-Mailadresse",
|
||||||
"base_url" => "Basis URL",
|
"base_url" => "Basis URL",
|
||||||
"user_registration_enabled" => "Benutzerregistrierung erlauben",
|
"user_registration_enabled" => "Benutzerregistrierung erlauben",
|
||||||
"allowed_extensions" => "Erlaubte Dateierweiterungen",
|
"allowed_extensions" => "Erlaubte Dateierweiterungen",
|
||||||
"trusted_domains" => "Vertraute Ursprungs-Domains (* als Subdomain-Wildcard)",
|
"trusted_domains" => "Vertraute Ursprungs-Domains (* als Subdomain-Wildcard)",
|
||||||
"time_zone" => "Zeitzone",
|
"time_zone" => "Zeitzone",
|
||||||
|
"mail_contact_gpg_key" => "Kontakt GPG-Schlüssel",
|
||||||
|
"no_gpg_key_configured" => "Noch kein GPG-Schlüssel konfiguriert",
|
||||||
|
|
||||||
# mail settings
|
# mail settings
|
||||||
"mail_enabled" => "E-Mail Versand aktiviert",
|
"mail_enabled" => "E-Mail Versand aktiviert",
|
||||||
@ -65,4 +68,8 @@ return [
|
|||||||
"save_settings_error" => "Fehler beim Speichern der Einstellungen",
|
"save_settings_error" => "Fehler beim Speichern der Einstellungen",
|
||||||
"send_test_email_error" => "Fehler beim Senden der Test E-Mail",
|
"send_test_email_error" => "Fehler beim Senden der Test E-Mail",
|
||||||
"send_test_email_success" => "Test E-Mail erfolgreich versendet, überprüfen Sie Ihren Posteingang!",
|
"send_test_email_success" => "Test E-Mail erfolgreich versendet, überprüfen Sie Ihren Posteingang!",
|
||||||
|
"remove_gpg_key_error" => "Fehler beim Entfernen des GPG-Schlüssels",
|
||||||
|
"remove_gpg_key" => "GPG-Schlüssel entfernen",
|
||||||
|
"remove_gpg_key_text" => "Möchten Sie wirklich diesen GPG-Schlüssel entfernen?",
|
||||||
|
"import_gpg_key_error" => "Fehler beim Importieren des GPG-Schlüssels",
|
||||||
];
|
];
|
@ -92,6 +92,11 @@ return [
|
|||||||
"remove_group_member_text" => "Do you really want to remove user '%s' from this group?",
|
"remove_group_member_text" => "Do you really want to remove user '%s' from this group?",
|
||||||
"add_group_member_title" => "Add member",
|
"add_group_member_title" => "Add member",
|
||||||
"add_group_member_text" => "Search a user to add to the group",
|
"add_group_member_text" => "Search a user to add to the group",
|
||||||
|
"delete_user_error" => "Error deleting User",
|
||||||
|
"delete_user_title" => "Delete User",
|
||||||
|
"delete_user_text" => "Are you really sure you want to delete this user? This cannot be undone!",
|
||||||
|
"error_reading_file" => "Error reading file",
|
||||||
|
"invalid_gpg_key" => "Selected file is a not a GPG Public Key in ASCII format",
|
||||||
|
|
||||||
# GPG Key
|
# GPG Key
|
||||||
"gpg_key" => "GPG Key",
|
"gpg_key" => "GPG Key",
|
||||||
|
@ -17,6 +17,7 @@ return [
|
|||||||
"available_groups" => "available Groups",
|
"available_groups" => "available Groups",
|
||||||
"routes_defined" => "Routes defined",
|
"routes_defined" => "Routes defined",
|
||||||
"error_count" => "Errors in the past 48 hours",
|
"error_count" => "Errors in the past 48 hours",
|
||||||
|
"more_info" => "More Info",
|
||||||
|
|
||||||
# Dialogs
|
# Dialogs
|
||||||
"fetch_stats_error" => "Error fetching stats",
|
"fetch_stats_error" => "Error fetching stats",
|
||||||
|
@ -54,10 +54,11 @@ return [
|
|||||||
"sending" => "Sending",
|
"sending" => "Sending",
|
||||||
|
|
||||||
# file
|
# file
|
||||||
"choose_file" => "Choose File",
|
"choose_file" => "Choose file",
|
||||||
"download" => "Download",
|
"download" => "Download",
|
||||||
"download_all" => "Download All",
|
"download_all" => "Download All",
|
||||||
"upload" => "Upload",
|
"upload" => "Upload",
|
||||||
|
"upload_file" => "Upload file",
|
||||||
"uploading" => "Uploading",
|
"uploading" => "Uploading",
|
||||||
"rename" => "Rename",
|
"rename" => "Rename",
|
||||||
"move" => "Move",
|
"move" => "Move",
|
||||||
|
@ -24,11 +24,14 @@ return [
|
|||||||
|
|
||||||
# general settings
|
# general settings
|
||||||
"site_name" => "Site Name",
|
"site_name" => "Site Name",
|
||||||
|
"mail_contact" => "Contact mail address",
|
||||||
"base_url" => "Base URL",
|
"base_url" => "Base URL",
|
||||||
"user_registration_enabled" => "Allow user registration",
|
"user_registration_enabled" => "Allow user registration",
|
||||||
"allowed_extensions" => "Allowed file extensions",
|
"allowed_extensions" => "Allowed file extensions",
|
||||||
"trusted_domains" => "Trusted origin domains (* as subdomain-wildcard)",
|
"trusted_domains" => "Trusted origin domains (* as subdomain-wildcard)",
|
||||||
"time_zone" => "Time zone",
|
"time_zone" => "Time zone",
|
||||||
|
"mail_contact_gpg_key" => "Contact GPG key",
|
||||||
|
"no_gpg_key_configured" => "No GPG key configured yet",
|
||||||
|
|
||||||
# mail settings
|
# mail settings
|
||||||
"mail_enabled" => "Enable e-mail transport",
|
"mail_enabled" => "Enable e-mail transport",
|
||||||
@ -65,4 +68,8 @@ return [
|
|||||||
"save_settings_error" => "Error saving settings",
|
"save_settings_error" => "Error saving settings",
|
||||||
"send_test_email_error" => "Error sending test email",
|
"send_test_email_error" => "Error sending test email",
|
||||||
"send_test_email_success" => "Test email successfully sent. Please check your inbox!",
|
"send_test_email_success" => "Test email successfully sent. Please check your inbox!",
|
||||||
|
"remove_gpg_key_error" => "Error removing GPG key",
|
||||||
|
"remove_gpg_key" => "Remove GPG key",
|
||||||
|
"remove_gpg_key_text" => "Do you really want to remove this gpg key?",
|
||||||
|
"import_gpg_key_error" => "Error importing GPG key",
|
||||||
];
|
];
|
@ -18,9 +18,9 @@ class GpgKey extends DatabaseEntity {
|
|||||||
private \DateTime $expires;
|
private \DateTime $expires;
|
||||||
#[DefaultValue(CurrentTimeStamp::class)] private \DateTime $added;
|
#[DefaultValue(CurrentTimeStamp::class)] private \DateTime $added;
|
||||||
|
|
||||||
public function __construct(string $fingerprint, string $algorithm, \DateTime $expires) {
|
public function __construct(string $fingerprint, string $algorithm, \DateTime $expires, bool $confirmed = false) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
$this->confirmed = false;
|
$this->confirmed = $confirmed;
|
||||||
$this->fingerprint = $fingerprint;
|
$this->fingerprint = $fingerprint;
|
||||||
$this->algorithm = $algorithm;
|
$this->algorithm = $algorithm;
|
||||||
$this->expires = $expires;
|
$this->expires = $expires;
|
||||||
|
@ -10,7 +10,7 @@ if (is_file($autoLoad)) {
|
|||||||
require_once $autoLoad;
|
require_once $autoLoad;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WEBBASE_VERSION = "2.4.0";
|
const WEBBASE_VERSION = "2.4.1";
|
||||||
|
|
||||||
spl_autoload_extensions(".php");
|
spl_autoload_extensions(".php");
|
||||||
spl_autoload_register(function ($class) {
|
spl_autoload_register(function ($class) {
|
||||||
|
53
cli.php
53
cli.php
@ -96,8 +96,57 @@ function handleDatabase(array $argv): void {
|
|||||||
$action = $argv[2] ?? "";
|
$action = $argv[2] ?? "";
|
||||||
|
|
||||||
if ($action === "migrate") {
|
if ($action === "migrate") {
|
||||||
|
$fileName = $argv[3] ?? "";
|
||||||
|
if (empty($fileName)) {
|
||||||
|
_exit("Usage: cli.php db migrate <file>");
|
||||||
|
}
|
||||||
|
|
||||||
|
$filePath = realpath($fileName);
|
||||||
|
if (!$filePath) {
|
||||||
|
_exit("File not found: $fileName");
|
||||||
|
}
|
||||||
|
|
||||||
|
$corePatches = implode(DIRECTORY_SEPARATOR, [WEBROOT, "Core", "Configuration", "Patch", ""]);
|
||||||
|
$sitePatches = implode(DIRECTORY_SEPARATOR, [WEBROOT, "Site", "Configuration", "Patch", ""]);
|
||||||
|
if (!endsWith($filePath, ".php") || (!startsWith($filePath, $corePatches) && !startsWith($filePath, $sitePatches))) {
|
||||||
|
_exit("invalid patch file: $filePath. Must be located in either Core or Site patch folder and have '.php' as extension");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
$sql = connectSQL() or die();
|
$sql = connectSQL() or die();
|
||||||
_exit("Not implemented: migrate");
|
$queries = [];
|
||||||
|
@include_once $filePath;
|
||||||
|
|
||||||
|
if (empty($queries)) {
|
||||||
|
_exit("No queries loaded.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$success = true;
|
||||||
|
$queryCount = count($queries);
|
||||||
|
$logger = new \Core\Driver\Logger\Logger("CLI", $sql);
|
||||||
|
$logger->info("Migrating DB with: " . $fileName);
|
||||||
|
printLine("Executing $queryCount queries");
|
||||||
|
|
||||||
|
$sql->startTransaction();
|
||||||
|
$queryIndex = 1;
|
||||||
|
foreach ($queries as $query) {
|
||||||
|
if ($query->execute() === false) {
|
||||||
|
$success = false;
|
||||||
|
printLine("Error executing query: " . $sql->getLastError());
|
||||||
|
$logger->error("Error while migrating db: " . $sql->getLastError());
|
||||||
|
$sql->rollback();
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
printLine("$queryIndex/$queryCount: success!");
|
||||||
|
$queryIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($success) {
|
||||||
|
$sql->commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
printLine("Done.");
|
||||||
} else if (in_array($action, ["export", "import", "shell"])) {
|
} else if (in_array($action, ["export", "import", "shell"])) {
|
||||||
|
|
||||||
// database config
|
// database config
|
||||||
@ -959,7 +1008,7 @@ class $apiName extends Request {
|
|||||||
$argv = $_SERVER['argv'];
|
$argv = $_SERVER['argv'];
|
||||||
$registeredCommands = [
|
$registeredCommands = [
|
||||||
"help" => ["handler" => "printHelp", "description" => "prints this help page"],
|
"help" => ["handler" => "printHelp", "description" => "prints this help page"],
|
||||||
"db" => ["handler" => "handleDatabase", "description" => "database actions like importing, exporting and shell"],
|
"db" => ["handler" => "handleDatabase", "description" => "database actions like importing, exporting and shell", "requiresDocker" => ["migrate"]],
|
||||||
"routes" => ["handler" => "onRoutes", "description" => "view and modify routes", "requiresDocker" => true],
|
"routes" => ["handler" => "onRoutes", "description" => "view and modify routes", "requiresDocker" => true],
|
||||||
"maintenance" => ["handler" => "onMaintenance", "description" => "toggle maintenance mode", "requiresDocker" => true],
|
"maintenance" => ["handler" => "onMaintenance", "description" => "toggle maintenance mode", "requiresDocker" => true],
|
||||||
"test" => ["handler" => "onTest", "description" => "run unit and integration tests", "requiresDocker" => true],
|
"test" => ["handler" => "onTest", "description" => "run unit and integration tests", "requiresDocker" => true],
|
||||||
|
15
react/admin-panel/src/elements/hidden-file-upload.js
Normal file
15
react/admin-panel/src/elements/hidden-file-upload.js
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import {styled} from "@mui/material";
|
||||||
|
|
||||||
|
const VisuallyHiddenInput = styled('input')({
|
||||||
|
clip: 'rect(0 0 0 0)',
|
||||||
|
clipPath: 'inset(50%)',
|
||||||
|
height: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
width: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default VisuallyHiddenInput;
|
@ -39,6 +39,12 @@ const StyledStatBox = styled(Alert)((props) => ({
|
|||||||
},
|
},
|
||||||
"& div:nth-of-type(1)": {
|
"& div:nth-of-type(1)": {
|
||||||
padding: props.theme.spacing(2),
|
padding: props.theme.spacing(2),
|
||||||
|
"& span": {
|
||||||
|
fontSize: "2.5em",
|
||||||
|
},
|
||||||
|
"& p": {
|
||||||
|
fontSize: "1em",
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"& div:nth-of-type(2) > svg": {
|
"& div:nth-of-type(2) > svg": {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
@ -49,8 +55,18 @@ const StyledStatBox = styled(Alert)((props) => ({
|
|||||||
},
|
},
|
||||||
"& div:nth-of-type(3)": {
|
"& div:nth-of-type(3)": {
|
||||||
backdropFilter: "brightness(70%)",
|
backdropFilter: "brightness(70%)",
|
||||||
textAlign: "right",
|
|
||||||
padding: props.theme.spacing(0.5),
|
padding: props.theme.spacing(0.5),
|
||||||
|
"& a": {
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "auto 30px",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "end",
|
||||||
|
textDecoration: "none",
|
||||||
|
"& svg": {
|
||||||
|
textAlign: "center",
|
||||||
|
justifySelf: "center"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@ -60,7 +76,7 @@ const StatBox = (props) => <StyledStatBox variant={"filled"} icon={false}
|
|||||||
<Box>
|
<Box>
|
||||||
{!isNaN(props.count) ?
|
{!isNaN(props.count) ?
|
||||||
<>
|
<>
|
||||||
<h2>{props.count}</h2>
|
<span>{props.count}</span>
|
||||||
<p>{props.text}</p>
|
<p>{props.text}</p>
|
||||||
</> : <CircularProgress variant={"determinate"} />
|
</> : <CircularProgress variant={"determinate"} />
|
||||||
}
|
}
|
||||||
@ -68,7 +84,8 @@ const StatBox = (props) => <StyledStatBox variant={"filled"} icon={false}
|
|||||||
<Box>{props.icon}</Box>
|
<Box>{props.icon}</Box>
|
||||||
<Box>
|
<Box>
|
||||||
<Link to={props.link}>
|
<Link to={props.link}>
|
||||||
More info <ArrowCircleRight />
|
<span>{props.L("admin.more_info")}</span>
|
||||||
|
<ArrowCircleRight />
|
||||||
</Link>
|
</Link>
|
||||||
</Box>
|
</Box>
|
||||||
</StyledStatBox>
|
</StyledStatBox>
|
||||||
@ -131,25 +148,25 @@ export default function Overview(props) {
|
|||||||
<StatBox color={"info"} count={stats?.userCount}
|
<StatBox color={"info"} count={stats?.userCount}
|
||||||
text={L("admin.users_registered")}
|
text={L("admin.users_registered")}
|
||||||
icon={<People/>}
|
icon={<People/>}
|
||||||
link={"/admin/users"}/>
|
link={"/admin/users"} L={L}/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={6} lg={3}>
|
<Grid item xs={6} lg={3}>
|
||||||
<StatBox color={"success"} count={stats?.groupCount}
|
<StatBox color={"success"} count={stats?.groupCount}
|
||||||
text={L("admin.available_groups")}
|
text={L("admin.available_groups")}
|
||||||
icon={<Groups/>}
|
icon={<Groups/>}
|
||||||
link={"/admin/groups"}/>
|
link={"/admin/groups"} L={L}/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={6} lg={3}>
|
<Grid item xs={6} lg={3}>
|
||||||
<StatBox color={"warning"} count={stats?.pageCount}
|
<StatBox color={"warning"} count={stats?.pageCount}
|
||||||
text={L("admin.routes_defined")}
|
text={L("admin.routes_defined")}
|
||||||
icon={<LibraryBooks/>}
|
icon={<LibraryBooks/>}
|
||||||
link={"/admin/routes"}/>
|
link={"/admin/routes"} L={L}/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={6} lg={3}>
|
<Grid item xs={6} lg={3}>
|
||||||
<StatBox color={"error"} count={stats?.errorCount}
|
<StatBox color={"error"} count={stats?.errorCount}
|
||||||
text={L("admin.error_count")}
|
text={L("admin.error_count")}
|
||||||
icon={<BugReport />}
|
icon={<BugReport />}
|
||||||
link={"/admin/logs"}/>
|
link={"/admin/logs"} L={L}/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Box m={2} p={2} component={Paper}>
|
<Box m={2} p={2} component={Paper}>
|
||||||
|
@ -5,6 +5,7 @@ import {CheckCircle, CloudUpload, ErrorOutline, Remove, Upload, VpnKey} from "@m
|
|||||||
import SpacedFormGroup from "../../elements/form-group";
|
import SpacedFormGroup from "../../elements/form-group";
|
||||||
import ButtonBar from "../../elements/button-bar";
|
import ButtonBar from "../../elements/button-bar";
|
||||||
import CollapseBox from "./collapse-box";
|
import CollapseBox from "./collapse-box";
|
||||||
|
import VisuallyHiddenInput from "../../elements/hidden-file-upload";
|
||||||
|
|
||||||
const GpgKeyField = styled(TextField)((props) => ({
|
const GpgKeyField = styled(TextField)((props) => ({
|
||||||
"& > div": {
|
"& > div": {
|
||||||
@ -24,18 +25,6 @@ const GpgFingerprintBox = styled(Box)((props) => ({
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const VisuallyHiddenInput = styled('input')({
|
|
||||||
clip: 'rect(0 0 0 0)',
|
|
||||||
clipPath: 'inset(50%)',
|
|
||||||
height: 1,
|
|
||||||
overflow: 'hidden',
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
width: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function GpgBox(props) {
|
export default function GpgBox(props) {
|
||||||
|
|
||||||
// meta
|
// meta
|
||||||
@ -87,7 +76,7 @@ export default function GpgBox(props) {
|
|||||||
data += event.target.result;
|
data += event.target.result;
|
||||||
if (reader.readyState === 2) {
|
if (reader.readyState === 2) {
|
||||||
if (!data.match(/^-+\s*BEGIN/m)) {
|
if (!data.match(/^-+\s*BEGIN/m)) {
|
||||||
showDialog(L("Selected file is a not a GPG Public Key in ASCII format"), L("Error reading file"));
|
showDialog(L("account.invalid_gpg_key"), L("account.error_reading_file"));
|
||||||
return false;
|
return false;
|
||||||
} else {
|
} else {
|
||||||
callback(data);
|
callback(data);
|
||||||
@ -98,9 +87,7 @@ export default function GpgBox(props) {
|
|||||||
reader.readAsText(file);
|
reader.readAsText(file);
|
||||||
}, [showDialog]);
|
}, [showDialog]);
|
||||||
|
|
||||||
return <CollapseBox title={L("account.gpg_key")} {...other}
|
return <CollapseBox title={L("account.gpg_key")} icon={<VpnKey />} {...other}>
|
||||||
|
|
||||||
icon={<VpnKey />}>
|
|
||||||
{
|
{
|
||||||
profile.gpgKey ? <Box>
|
profile.gpgKey ? <Box>
|
||||||
<GpgFingerprintBox mb={2}>
|
<GpgFingerprintBox mb={2}>
|
||||||
@ -150,8 +137,8 @@ export default function GpgBox(props) {
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
startIcon={<CloudUpload />}
|
startIcon={<CloudUpload />}
|
||||||
component={"label"}>
|
component={"label"}>
|
||||||
Upload file
|
{L("general.upload_file")}
|
||||||
<VisuallyHiddenInput type={"file"} onChange={e => {
|
<VisuallyHiddenInput type={"file"} onChange={e => {
|
||||||
let file = e.target.files[0];
|
let file = e.target.files[0];
|
||||||
getFileContents(file, (data) => {
|
getFileContents(file, (data) => {
|
||||||
setGpgKey(data);
|
setGpgKey(data);
|
||||||
|
98
react/admin-panel/src/views/settings/input-gpg-key.js
Normal file
98
react/admin-panel/src/views/settings/input-gpg-key.js
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import {Box, IconButton, styled, TextField} from "@mui/material";
|
||||||
|
import {Delete, Upload} from "@mui/icons-material";
|
||||||
|
import React, {useCallback, useContext, useRef, useState} from "react";
|
||||||
|
import {LocaleContext} from "shared/locale";
|
||||||
|
import VisuallyHiddenInput from "../../elements/hidden-file-upload";
|
||||||
|
|
||||||
|
const StyledGpgKeyInput = styled(Box)((props) => ({
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "40px auto",
|
||||||
|
"& button": {
|
||||||
|
padding: 0,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: props.theme.palette.grey[400],
|
||||||
|
borderTopLeftRadius: 5,
|
||||||
|
borderBottomLeftRadius: 5,
|
||||||
|
borderTopRightRadius: 0,
|
||||||
|
borderBottomRightRadius: 0,
|
||||||
|
backgroundColor: props.theme.palette.grey[300],
|
||||||
|
},
|
||||||
|
"& > div > div": {
|
||||||
|
borderTopLeftRadius: 0,
|
||||||
|
borderBottomLeftRadius: 0,
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default function GpgKeyInput(props) {
|
||||||
|
|
||||||
|
const { value, api, showDialog, onChange, ...other } = props;
|
||||||
|
const {translate: L} = useContext(LocaleContext);
|
||||||
|
const isConfigured = !!value;
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
|
|
||||||
|
const onRemoveKey = useCallback(() => {
|
||||||
|
api.settingsRemoveGPG().then(data => {
|
||||||
|
if (!data.success) {
|
||||||
|
showDialog(data.msg, L("settings.remove_gpg_key_error"));
|
||||||
|
} else {
|
||||||
|
onChange(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [api, showDialog, onChange]);
|
||||||
|
|
||||||
|
const onImportGPG = useCallback((publicKey) => {
|
||||||
|
api.settingsImportGPG(publicKey).then(data => {
|
||||||
|
if (!data.success) {
|
||||||
|
showDialog(data.msg, L("settings.import_gpg_key_error"));
|
||||||
|
} else {
|
||||||
|
onChange(data.gpgKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [api, showDialog, onChange]);
|
||||||
|
|
||||||
|
const onOpenDialog = useCallback(() => {
|
||||||
|
if (isConfigured) {
|
||||||
|
showDialog(
|
||||||
|
L("settings.remove_gpg_key_text"),
|
||||||
|
L("settings.remove_gpg_key"),
|
||||||
|
[L("general.cancel"), L("general.remove")],
|
||||||
|
button => button === 1 ? onRemoveKey() : true
|
||||||
|
);
|
||||||
|
} else if (fileInputRef?.current) {
|
||||||
|
fileInputRef.current.click();
|
||||||
|
}
|
||||||
|
}, [showDialog, isConfigured, onRemoveKey, fileInputRef?.current]);
|
||||||
|
|
||||||
|
const getFileContents = useCallback((file, callback) => {
|
||||||
|
let reader = new FileReader();
|
||||||
|
let data = "";
|
||||||
|
reader.onload = function(event) {
|
||||||
|
data += event.target.result;
|
||||||
|
if (reader.readyState === 2) {
|
||||||
|
if (!data.match(/^-+\s*BEGIN/m)) {
|
||||||
|
showDialog(L("account.invalid_gpg_key"), L("account.error_reading_file"));
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
callback(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
}, [showDialog]);
|
||||||
|
|
||||||
|
return <StyledGpgKeyInput {...other}>
|
||||||
|
<IconButton onClick={onOpenDialog}>
|
||||||
|
{ isConfigured ? <Delete color={"error"} /> : <Upload color={"success"} /> }
|
||||||
|
</IconButton>
|
||||||
|
<VisuallyHiddenInput ref={fileInputRef} type={"file"} onChange={e => {
|
||||||
|
let file = e.target.files[0];
|
||||||
|
getFileContents(file, (data) => {
|
||||||
|
onImportGPG(data);
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}} />
|
||||||
|
<TextField variant={"outlined"} size={"small"} disabled={true}
|
||||||
|
value={value?.fingerprint ?? L("settings.no_gpg_key_configured")} />
|
||||||
|
</StyledGpgKeyInput>
|
||||||
|
}
|
@ -3,7 +3,7 @@ import {LocaleContext} from "shared/locale";
|
|||||||
import {
|
import {
|
||||||
Box, Button,
|
Box, Button,
|
||||||
CircularProgress, FormControl,
|
CircularProgress, FormControl,
|
||||||
FormGroup, FormLabel, Grid, IconButton,
|
FormLabel, Grid, IconButton,
|
||||||
Paper,
|
Paper,
|
||||||
Tab,
|
Tab,
|
||||||
Table,
|
Table,
|
||||||
@ -23,7 +23,7 @@ import {
|
|||||||
RestartAlt,
|
RestartAlt,
|
||||||
Save,
|
Save,
|
||||||
Send,
|
Send,
|
||||||
SettingsApplications, SmartToy, Storage
|
SettingsApplications, SmartToy, Storage,
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
import TIME_ZONES from "shared/time-zones";
|
import TIME_ZONES from "shared/time-zones";
|
||||||
import ButtonBar from "../../elements/button-bar";
|
import ButtonBar from "../../elements/button-bar";
|
||||||
@ -34,10 +34,12 @@ import SettingsPasswordInput from "./input-password";
|
|||||||
import SettingsTextInput from "./input-text";
|
import SettingsTextInput from "./input-text";
|
||||||
import SettingsSelection from "./input-selection";
|
import SettingsSelection from "./input-selection";
|
||||||
import ViewContent from "../../elements/view-content";
|
import ViewContent from "../../elements/view-content";
|
||||||
|
import GpgKeyInput from "./input-gpg-key";
|
||||||
|
import SpacedFormGroup from "../../elements/form-group";
|
||||||
|
|
||||||
export default function SettingsView(props) {
|
export default function SettingsView(props) {
|
||||||
|
|
||||||
// TODO: website-logo (?), mail_contact, mail_contact_gpg_key_id
|
// TODO: website-logo (?), mail_contact_gpg_key_id
|
||||||
|
|
||||||
// meta
|
// meta
|
||||||
const api = props.api;
|
const api = props.api;
|
||||||
@ -47,6 +49,7 @@ export default function SettingsView(props) {
|
|||||||
"general": [
|
"general": [
|
||||||
"base_url",
|
"base_url",
|
||||||
"site_name",
|
"site_name",
|
||||||
|
"mail_contact",
|
||||||
"user_registration_enabled",
|
"user_registration_enabled",
|
||||||
"time_zone",
|
"time_zone",
|
||||||
"allowed_extensions",
|
"allowed_extensions",
|
||||||
@ -75,6 +78,8 @@ export default function SettingsView(props) {
|
|||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CUSTOM_KEYS = ["mail_contact_gpg_key"];
|
||||||
|
|
||||||
// data
|
// data
|
||||||
const [fetchSettings, setFetchSettings] = useState(true);
|
const [fetchSettings, setFetchSettings] = useState(true);
|
||||||
const [settings, setSettings] = useState(null);
|
const [settings, setSettings] = useState(null);
|
||||||
@ -94,8 +99,12 @@ export default function SettingsView(props) {
|
|||||||
}, [])).includes(key);
|
}, [])).includes(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isCustom = (key) => {
|
||||||
|
return CUSTOM_KEYS.includes(key);
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
requestModules(props.api, ["general", "settings"], currentLocale).then(data => {
|
requestModules(props.api, ["general", "settings", "account"], currentLocale).then(data => {
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
showDialog("Error fetching translations: " + data.msg);
|
showDialog("Error fetching translations: " + data.msg);
|
||||||
}
|
}
|
||||||
@ -115,7 +124,9 @@ export default function SettingsView(props) {
|
|||||||
return obj;
|
return obj;
|
||||||
}, {})
|
}, {})
|
||||||
);
|
);
|
||||||
setUncategorizedKeys(Object.keys(data.settings).filter(key => isUncategorized(key)));
|
setUncategorizedKeys(Object.keys(data.settings)
|
||||||
|
.filter(key => !isCustom(key))
|
||||||
|
.filter(key => isUncategorized(key)));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -132,7 +143,15 @@ export default function SettingsView(props) {
|
|||||||
|
|
||||||
const onSaveSettings = useCallback(() => {
|
const onSaveSettings = useCallback(() => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
api.saveSettings(settings).then(data => {
|
|
||||||
|
let settingsToSave = {...settings};
|
||||||
|
for (const key of CUSTOM_KEYS) {
|
||||||
|
if (settingsToSave.hasOwnProperty(key)) {
|
||||||
|
delete settingsToSave[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
api.saveSettings(settingsToSave).then(data => {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
showDialog(L("settings.save_settings_success"), L("general.success"));
|
showDialog(L("settings.save_settings_success"), L("general.success"));
|
||||||
@ -253,6 +272,13 @@ export default function SettingsView(props) {
|
|||||||
if (selectedTab === "general") {
|
if (selectedTab === "general") {
|
||||||
return [
|
return [
|
||||||
renderTextInput("site_name"),
|
renderTextInput("site_name"),
|
||||||
|
renderTextInput("mail_contact", false, {type: "email"}),
|
||||||
|
<SpacedFormGroup key={"mail-contact-gpg-key"}>
|
||||||
|
<FormLabel>{L("settings.mail_contact_gpg_key")}</FormLabel>
|
||||||
|
<GpgKeyInput value={settings.mail_contact_gpg_key} api={api}
|
||||||
|
showDialog={showDialog}
|
||||||
|
onChange={value => setSettings({...settings, mail_contact_gpg_key: value})}/>
|
||||||
|
</SpacedFormGroup>,
|
||||||
renderTextInput("base_url"),
|
renderTextInput("base_url"),
|
||||||
renderTextValuesInput("trusted_domains"),
|
renderTextValuesInput("trusted_domains"),
|
||||||
renderCheckBox("user_registration_enabled"),
|
renderCheckBox("user_registration_enabled"),
|
||||||
@ -269,7 +295,7 @@ export default function SettingsView(props) {
|
|||||||
renderPasswordInput("mail_password", !settings.mail_enabled),
|
renderPasswordInput("mail_password", !settings.mail_enabled),
|
||||||
renderTextInput("mail_footer", !settings.mail_enabled),
|
renderTextInput("mail_footer", !settings.mail_enabled),
|
||||||
renderCheckBox("mail_async", !settings.mail_enabled),
|
renderCheckBox("mail_async", !settings.mail_enabled),
|
||||||
<FormGroup key={"mail-test"}>
|
<SpacedFormGroup key={"mail-test"}>
|
||||||
<FormLabel>{L("settings.send_test_email")}</FormLabel>
|
<FormLabel>{L("settings.send_test_email")}</FormLabel>
|
||||||
<FormControl disabled={!settings.mail_enabled}>
|
<FormControl disabled={!settings.mail_enabled}>
|
||||||
<Grid container spacing={1}>
|
<Grid container spacing={1}>
|
||||||
@ -292,7 +318,7 @@ export default function SettingsView(props) {
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormGroup>
|
</SpacedFormGroup>
|
||||||
];
|
];
|
||||||
} else if (selectedTab === "captcha") {
|
} else if (selectedTab === "captcha") {
|
||||||
let captchaOptions = {};
|
let captchaOptions = {};
|
||||||
|
@ -16,7 +16,7 @@ import * as React from "react";
|
|||||||
import ViewContent from "../../elements/view-content";
|
import ViewContent from "../../elements/view-content";
|
||||||
import FormGroup from "../../elements/form-group";
|
import FormGroup from "../../elements/form-group";
|
||||||
import ButtonBar from "../../elements/button-bar";
|
import ButtonBar from "../../elements/button-bar";
|
||||||
import {RestartAlt, Save, Send} from "@mui/icons-material";
|
import {Delete, RestartAlt, Save, Send} from "@mui/icons-material";
|
||||||
import PasswordStrength from "shared/elements/password-strength";
|
import PasswordStrength from "shared/elements/password-strength";
|
||||||
|
|
||||||
const initialUser = {
|
const initialUser = {
|
||||||
@ -51,20 +51,20 @@ export default function UserEditView(props) {
|
|||||||
const [sendInvite, setSetInvite] = useState(isNewUser);
|
const [sendInvite, setSetInvite] = useState(isNewUser);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
requestModules(props.api, ["general", "account"], currentLocale).then(data => {
|
requestModules(api, ["general", "account"], currentLocale).then(data => {
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
props.showDialog("Error fetching translations: " + data.msg);
|
showDialog("Error fetching translations: " + data.msg);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [currentLocale]);
|
}, [currentLocale]);
|
||||||
|
|
||||||
const onFetchGroups = useCallback(() => {
|
const onFetchGroups = useCallback(() => {
|
||||||
api.searchGroups(groupInput, user?.groups?.map(group => group.id)).then((res) => {
|
api.searchGroups(groupInput, user?.groups?.map(group => group.id)).then((res) => {
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
setGroups(res.groups);
|
setGroups(res.groups);
|
||||||
} else {
|
} else {
|
||||||
showDialog(res.msg, L("account.search_groups_error"));
|
showDialog(res.msg, L("account.search_groups_error"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [api, showDialog, user?.groups, groupInput]);
|
}, [api, showDialog, user?.groups, groupInput]);
|
||||||
|
|
||||||
@ -110,7 +110,7 @@ export default function UserEditView(props) {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
api.createUser(user.name, user.fullName, user.email, groupIds,
|
api.createUser(user.name, user.fullName, user.email, groupIds,
|
||||||
user.password, user.passwordConfirm
|
user.password, user.passwordConfirm
|
||||||
).then(res => {
|
).then(res => {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
@ -143,6 +143,16 @@ export default function UserEditView(props) {
|
|||||||
setChanged(true);
|
setChanged(true);
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
|
const onDeleteUser = useCallback(() => {
|
||||||
|
api.deleteUser(userId).then(res => {
|
||||||
|
if (res.success) {
|
||||||
|
navigate("/admin/users");
|
||||||
|
} else {
|
||||||
|
showDialog(res.msg, L("account.delete_user_error"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [api, showDialog, userId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isNewUser) {
|
if (!isNewUser) {
|
||||||
onFetchUser(true);
|
onFetchUser(true);
|
||||||
@ -163,118 +173,132 @@ export default function UserEditView(props) {
|
|||||||
<span key={"action"}>{isNewUser ? L("general.new") : L("general.edit")}</span>
|
<span key={"action"}>{isNewUser ? L("general.new") : L("general.edit")}</span>
|
||||||
]}>
|
]}>
|
||||||
<Grid container>
|
<Grid container>
|
||||||
|
<Grid item xs={12} mt={1} mb={1}>
|
||||||
|
<Button variant={"outlined"} color={"error"} size={"small"}
|
||||||
|
startIcon={<Delete />}
|
||||||
|
disabled={isNewUser || !api.hasPermission("user/delete") || user.id === api.user.id}
|
||||||
|
onClick={() => showDialog(
|
||||||
|
L("account.delete_user_text"),
|
||||||
|
L("account.delete_user_title"),
|
||||||
|
[L("general.cancel"), L("general.confirm")],
|
||||||
|
(buttonIndex) => buttonIndex === 1 ? onDeleteUser() : true)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{L("general.delete")}
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
<Grid item xs={12} lg={6}>
|
<Grid item xs={12} lg={6}>
|
||||||
<FormGroup>
|
<FormGroup>
|
||||||
<FormLabel>{L("account.name")}</FormLabel>
|
<FormLabel>{L("account.name")}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<TextField size={"small"} variant={"outlined"}
|
<TextField size={"small"} variant={"outlined"}
|
||||||
value={user.name}
|
value={user.name}
|
||||||
onChange={e => onChangeValue("name", e.target.value)} />
|
onChange={e => onChangeValue("name", e.target.value)} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
<FormGroup>
|
<FormGroup>
|
||||||
<FormLabel>{L("account.full_name")}</FormLabel>
|
<FormLabel>{L("account.full_name")}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<TextField size={"small"} variant={"outlined"}
|
<TextField size={"small"} variant={"outlined"}
|
||||||
value={user.fullName}
|
value={user.fullName}
|
||||||
onChange={e => onChangeValue("fullName", e.target.value)} />
|
onChange={e => onChangeValue("fullName", e.target.value)} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
<FormGroup>
|
<FormGroup>
|
||||||
<FormLabel>{L("account.email")}</FormLabel>
|
<FormLabel>{L("account.email")}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<TextField size={"small"} variant={"outlined"}
|
<TextField size={"small"} variant={"outlined"}
|
||||||
value={user.email ?? ""}
|
value={user.email ?? ""}
|
||||||
type={"email"}
|
type={"email"}
|
||||||
onChange={e => onChangeValue("email", e.target.value)} />
|
onChange={e => onChangeValue("email", e.target.value)} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
<FormGroup>
|
<FormGroup>
|
||||||
<FormLabel>{L("account.groups")}</FormLabel>
|
<FormLabel>{L("account.groups")}</FormLabel>
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
options={Object.values(groups || {})}
|
options={Object.values(groups || {})}
|
||||||
getOptionLabel={group => group.name}
|
getOptionLabel={group => group.name}
|
||||||
getOptionKey={group => group.id}
|
getOptionKey={group => group.id}
|
||||||
filterOptions={(options) => options}
|
filterOptions={(options) => options}
|
||||||
clearOnBlur={true}
|
clearOnBlur={true}
|
||||||
clearOnEscape
|
clearOnEscape
|
||||||
freeSolo
|
freeSolo
|
||||||
multiple
|
multiple
|
||||||
value={user.groups}
|
value={user.groups}
|
||||||
inputValue={groupInput}
|
inputValue={groupInput}
|
||||||
onChange={(e, v) => onChangeValue("groups", v)}
|
onChange={(e, v) => onChangeValue("groups", v)}
|
||||||
onInputChange={e => setGroupInput((!e || e.target.value === 0) ? "" : e.target.value) }
|
onInputChange={e => setGroupInput((!e || e.target.value === 0) ? "" : e.target.value) }
|
||||||
renderTags={(values, props) =>
|
renderTags={(values, props) =>
|
||||||
values.map((option, index) => {
|
values.map((option, index) => {
|
||||||
return <Chip label={option.name}
|
return <Chip label={option.name}
|
||||||
style={{backgroundColor: option.color}}
|
style={{backgroundColor: option.color}}
|
||||||
{...props({index})} />
|
{...props({index})} />
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
renderInput={(params) => <TextField {...params}
|
renderInput={(params) => <TextField {...params}
|
||||||
onBlur={() => setGroupInput("")} />}
|
onBlur={() => setGroupInput("")} />}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
{ !isNewUser ?
|
{ !isNewUser ?
|
||||||
<>
|
<>
|
||||||
<FormGroup>
|
|
||||||
<FormLabel>{L("account.password")}</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<TextField size={"small"} variant={"outlined"}
|
|
||||||
value={user.password}
|
|
||||||
type={"password"}
|
|
||||||
placeholder={"(" + L("general.unchanged") + ")"}
|
|
||||||
onChange={e => onChangeValue("password", e.target.value)} />
|
|
||||||
</FormControl>
|
|
||||||
</FormGroup>
|
|
||||||
<MuiFormGroup>
|
|
||||||
<FormControlLabel
|
|
||||||
control={<Checkbox
|
|
||||||
checked={!!user.active}
|
|
||||||
onChange={(e, v) => onChangeValue("active", v)} />}
|
|
||||||
label={L("account.active")} />
|
|
||||||
</MuiFormGroup>
|
|
||||||
<FormGroup>
|
|
||||||
<FormControlLabel
|
|
||||||
control={<Checkbox
|
|
||||||
checked={!!user.confirmed}
|
|
||||||
onChange={(e, v) => onChangeValue("confirmed", v)} />}
|
|
||||||
label={L("account.confirmed")} />
|
|
||||||
</FormGroup>
|
|
||||||
</> : <>
|
|
||||||
<FormGroup>
|
|
||||||
<FormControlLabel
|
|
||||||
control={<Checkbox
|
|
||||||
checked={sendInvite}
|
|
||||||
onChange={(e, v) => setSetInvite(v)} />}
|
|
||||||
label={L("account.send_invite")} />
|
|
||||||
</FormGroup>
|
|
||||||
{!sendInvite && <>
|
|
||||||
<FormGroup>
|
<FormGroup>
|
||||||
<FormLabel>{L("account.password")}</FormLabel>
|
<FormLabel>{L("account.password")}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<TextField size={"small"} variant={"outlined"}
|
<TextField size={"small"} variant={"outlined"}
|
||||||
value={user.password}
|
value={user.password}
|
||||||
type={"password"}
|
type={"password"}
|
||||||
|
placeholder={"(" + L("general.unchanged") + ")"}
|
||||||
onChange={e => onChangeValue("password", e.target.value)} />
|
onChange={e => onChangeValue("password", e.target.value)} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
|
<MuiFormGroup>
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Checkbox
|
||||||
|
checked={!!user.active}
|
||||||
|
onChange={(e, v) => onChangeValue("active", v)} />}
|
||||||
|
label={L("account.active")} />
|
||||||
|
</MuiFormGroup>
|
||||||
<FormGroup>
|
<FormGroup>
|
||||||
<FormLabel>{L("account.password_confirm")}</FormLabel>
|
<FormControlLabel
|
||||||
<FormControl>
|
control={<Checkbox
|
||||||
<TextField size={"small"} variant={"outlined"}
|
checked={!!user.confirmed}
|
||||||
value={user.passwordConfirm}
|
onChange={(e, v) => onChangeValue("confirmed", v)} />}
|
||||||
type={"password"}
|
label={L("account.confirmed")} />
|
||||||
onChange={e => onChangeValue("passwordConfirm", e.target.value)} />
|
|
||||||
</FormControl>
|
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
<Box mb={2}>
|
</> : <>
|
||||||
<PasswordStrength password={user.password} />
|
<FormGroup>
|
||||||
</Box>
|
<FormControlLabel
|
||||||
|
control={<Checkbox
|
||||||
|
checked={sendInvite}
|
||||||
|
onChange={(e, v) => setSetInvite(v)} />}
|
||||||
|
label={L("account.send_invite")} />
|
||||||
|
</FormGroup>
|
||||||
|
{!sendInvite && <>
|
||||||
|
<FormGroup>
|
||||||
|
<FormLabel>{L("account.password")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<TextField size={"small"} variant={"outlined"}
|
||||||
|
value={user.password}
|
||||||
|
type={"password"}
|
||||||
|
onChange={e => onChangeValue("password", e.target.value)} />
|
||||||
|
</FormControl>
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup>
|
||||||
|
<FormLabel>{L("account.password_confirm")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<TextField size={"small"} variant={"outlined"}
|
||||||
|
value={user.passwordConfirm}
|
||||||
|
type={"password"}
|
||||||
|
onChange={e => onChangeValue("passwordConfirm", e.target.value)} />
|
||||||
|
</FormControl>
|
||||||
|
</FormGroup>
|
||||||
|
<Box mb={2}>
|
||||||
|
<PasswordStrength password={user.password} />
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
</>
|
|
||||||
}
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
<ButtonBar>
|
<ButtonBar>
|
||||||
|
@ -302,6 +302,14 @@ export default class API {
|
|||||||
return this.apiCall("settings/set", { settings: settings });
|
return this.apiCall("settings/set", { settings: settings });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async settingsImportGPG(publicKey) {
|
||||||
|
return this.apiCall("settings/importGPG", { publicKey: publicKey });
|
||||||
|
}
|
||||||
|
|
||||||
|
async settingsRemoveGPG() {
|
||||||
|
return this.apiCall("settings/removeGPG");
|
||||||
|
}
|
||||||
|
|
||||||
/** MailAPI **/
|
/** MailAPI **/
|
||||||
async sendTestMail(receiver) {
|
async sendTestMail(receiver) {
|
||||||
return this.apiCall("mail/test", { receiver: receiver });
|
return this.apiCall("mail/test", { receiver: receiver });
|
||||||
@ -396,8 +404,8 @@ export default class API {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** GPG API **/
|
/** GPG API **/
|
||||||
async uploadGPG(pubkey) {
|
async uploadGPG(publicKey) {
|
||||||
let res = await this.apiCall("gpgKey/import", { pubkey: pubkey });
|
let res = await this.apiCall("gpgKey/import", { publicKey: publicKey });
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
this.user.gpgKey = res.gpgKey;
|
this.user.gpgKey = res.gpgKey;
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user