diff --git a/Core/API/GpgKeyAPI.class.php b/Core/API/GpgKeyAPI.class.php index b617e3e..30cf0ee 100644 --- a/Core/API/GpgKeyAPI.class.php +++ b/Core/API/GpgKeyAPI.class.php @@ -10,7 +10,6 @@ namespace Core\API { $this->loginRequired = true; } } - } namespace Core\API\GpgKey { @@ -20,6 +19,7 @@ namespace Core\API\GpgKey { use Core\API\Parameter\Parameter; use Core\API\Parameter\StringType; use Core\API\Template\Render; + use Core\API\Traits\GpgKeyValidation; use Core\Driver\SQL\Condition\Compare; use Core\Objects\Context; use Core\Objects\DatabaseEntity\GpgKey; @@ -28,36 +28,16 @@ namespace Core\API\GpgKey { class Import extends GpgKeyAPI { + use GpgKeyValidation; + public function __construct(Context $context, bool $externalCall = false) { parent::__construct($context, $externalCall, [ - "pubkey" => new StringType("pubkey") + "publicKey" => new StringType("publicKey") ]); $this->loginRequired = true; $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 { $currentUser = $this->context->getUser(); @@ -69,8 +49,7 @@ namespace Core\API\GpgKey { } // fix key first, enforce a newline after - $keyString = $this->getParam("pubkey"); - $keyString = preg_replace("/(-{2,})\n([^\n])/", "$1\n\n$2", $keyString); + $keyString = $this->formatKey($this->getParam("publicKey")); $keyData = $this->testKey($keyString); if ($keyData === false) { return false; diff --git a/Core/API/Request.class.php b/Core/API/Request.class.php index e501db0..47f8268 100644 --- a/Core/API/Request.class.php +++ b/Core/API/Request.class.php @@ -215,38 +215,15 @@ abstract class Request { return false; } - if ($this->isMethodAllowed("GET") && $this->isMethodAllowed("POST")) { - $values = $_REQUEST; - } else if ($this->isMethodAllowed("POST")) { - $values = $_POST; - } else if ($this->isMethodAllowed("GET")) { - $values = $_GET; - } - - if (in_array($_SERVER['REQUEST_METHOD'], ['POST', 'PUT', 'PATCH'])) { - $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; - } + $values = $_REQUEST; + if ($_SERVER['REQUEST_METHOD'] === 'POST' && in_array("application/json", explode(";", $_SERVER["CONTENT_TYPE"] ?? ""))) { + $jsonData = json_decode(file_get_contents('php://input'), 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; } - return $obj[$name]?->value; + // I don't know why phpstorm + return (isset($obj[$name]) ? $obj[$name]->value : NULL); } public function isMethodAllowed(string $method): bool { diff --git a/Core/API/SettingsAPI.class.php b/Core/API/SettingsAPI.class.php index e6d2867..26e4928 100644 --- a/Core/API/SettingsAPI.class.php +++ b/Core/API/SettingsAPI.class.php @@ -19,6 +19,7 @@ namespace Core\API { // API parameters should be more configurable, e.g. allow regexes, min/max values for numbers, etc. $this->predefinedKeys = [ "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), "user_registration_enabled" => new Parameter("user_registration_enabled", Parameter::TYPE_BOOLEAN), "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\StringType; use Core\API\SettingsAPI; + use Core\API\Traits\GpgKeyValidation; use Core\Configuration\Settings; use Core\Driver\SQL\Column\Column; use Core\Driver\SQL\Condition\CondBool; use Core\Driver\SQL\Condition\CondIn; use Core\Driver\SQL\Strategy\UpdateStrategy; use Core\Objects\Context; + use Core\Objects\DatabaseEntity\GpgKey; use Core\Objects\DatabaseEntity\Group; class Get extends SettingsAPI { + private ?GpgKey $contactGpgKey; + public function __construct(Context $context, bool $externalCall = false) { parent::__construct($context, $externalCall, array( 'key' => new StringType('key', -1, true, NULL) )); + $this->contactGpgKey = null; } public function _execute(): bool { $key = $this->getParam("key"); $sql = $this->context->getSQL(); + $siteSettings = $this->context->getSettings(); $settings = Settings::getAll($sql, $key, $this->isExternalCall()); if ($settings !== null) { $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 { return $this->createError("Error fetching settings: " . $sql->getLastError()); } @@ -68,6 +81,10 @@ namespace Core\API\Settings { return $this->success; } + public function getContactGpgKey(): ?GpgKey { + return $this->contactGpgKey; + } + public static function getDescription(): string { return "Allows users to fetch site settings"; } @@ -138,7 +155,6 @@ namespace Core\API\Settings { ["value" => new Column("value")]) ); - $this->success = ($query->execute() !== FALSE); $this->lastError = $sql->getLastError(); @@ -188,4 +204,90 @@ namespace Core\API\Settings { 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]; + } + } } \ No newline at end of file diff --git a/Core/API/Traits/GpgKeyValidation.trait.php b/Core/API/Traits/GpgKeyValidation.trait.php new file mode 100644 index 0000000..214a404 --- /dev/null +++ b/Core/API/Traits/GpgKeyValidation.trait.php @@ -0,0 +1,34 @@ +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); + } +} \ No newline at end of file diff --git a/Core/API/UserAPI.class.php b/Core/API/UserAPI.class.php index 6655d54..eed0bfa 100644 --- a/Core/API/UserAPI.class.php +++ b/Core/API/UserAPI.class.php @@ -1008,7 +1008,15 @@ namespace Core\API\User { } else { $this->success = ($user->delete($sql) !== FALSE); $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; diff --git a/Core/Configuration/Patch/2024_05_11-Settings-GPG.php b/Core/Configuration/Patch/2024_05_11-Settings-GPG.php new file mode 100644 index 0000000..34582a8 --- /dev/null +++ b/Core/Configuration/Patch/2024_05_11-Settings-GPG.php @@ -0,0 +1,25 @@ +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); diff --git a/Core/Configuration/Settings.class.php b/Core/Configuration/Settings.class.php index ea90738..4b8de70 100644 --- a/Core/Configuration/Settings.class.php +++ b/Core/Configuration/Settings.class.php @@ -17,6 +17,7 @@ use Core\Objects\Captcha\GoogleRecaptchaProvider; use Core\Objects\Captcha\HCaptchaProvider; use Core\Objects\ConnectionData; use Core\Objects\Context; +use Core\Objects\DatabaseEntity\GpgKey; class Settings { @@ -25,6 +26,11 @@ class Settings { // general settings private string $siteName; + + private string $contactMail; + + private ?GpgKey $contactGpgKey; + private string $baseUrl; private array $trustedDomains; private bool $registrationAllowed; @@ -101,6 +107,8 @@ class Settings { // General $settings->siteName = "WebBase"; + $settings->contactMail = "webmaster@$hostname"; + $settings->contactGpgKey = null; $settings->baseUrl = "$protocol://$hostname"; $settings->trustedDomains = [$hostname]; $settings->allowedExtensions = ['png', 'jpg', 'jpeg', 'gif', 'htm', 'html']; @@ -137,13 +145,15 @@ class Settings { } 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); $success = $req->execute(); if ($success) { $result = $req->getResult()["settings"]; $this->siteName = $result["site_name"] ?? $this->siteName; + $this->contactMail = $result["mail_contact"] ?? $this->contactMail; $this->baseUrl = $result["base_url"] ?? $this->baseUrl; $this->registrationAllowed = $result["user_registration_enabled"] ?? $this->registrationAllowed; $this->installationComplete = $result["installation_completed"] ?? $this->installationComplete; @@ -162,13 +172,18 @@ class Settings { $this->redisPort = $result["redis_port"] ?? $this->redisPort; $this->redisPassword = $result["redis_password"] ?? $this->redisPassword; date_default_timezone_set($this->timeZone); + + $this->contactGpgKey = $req->getContactGpgKey(); } return false; } public function addRows(Insert $query): void { + // ["name", "value", "private", "readonly"] $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("trusted_domains", json_encode($this->trustedDomains), false, false) ->addRow("user_registration_enabled", json_encode($this->registrationAllowed), false, false) @@ -196,6 +211,14 @@ class Settings { return $this->siteName; } + public function getContactMail(): string { + return $this->contactMail; + } + + public function getContactGPGKey(): ?GpgKey { + return $this->contactGpgKey; + } + public function getTimeZone(): string { return $this->timeZone; } diff --git a/Core/Documents/Install.class.php b/Core/Documents/Install.class.php index e7a988c..5c7e8f5 100644 --- a/Core/Documents/Install.class.php +++ b/Core/Documents/Install.class.php @@ -212,7 +212,7 @@ namespace Documents\Install { $step = self::FINISH_INSTALLATION; $req = new \Core\API\Settings\Set($context); - $success = $req->execute(["settings" => ["installation_completed" => "1"]]); + $success = $req->execute(["settings" => ["installation_completed" => true]]); if (!$success) { $this->errorString = $req->getLastError(); } @@ -633,12 +633,12 @@ namespace Documents\Install { } $items[] = html_tag("li", $attr, [ - html_tag("div", [], [ - html_tag("h6", ["class" => "my-0"], $title), - html_tag("small", ["class" => "text-$statusColor"], $statusText), - ], false), - html_tag("span", ["class" => "text-$statusColor"], $statusIcon, false) - ], false); + html_tag("div", [], [ + html_tag("h6", ["class" => "my-0"], $title), + html_tag("small", ["class" => "text-$statusColor"], $statusText), + ], false), + html_tag("span", ["class" => "text-$statusColor"], $statusIcon, false) + ], false); } return $items; @@ -956,7 +956,7 @@ namespace Documents\Install { 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("span", ["class" => "text-muted"], "Progress"), - false + false ), html_tag("ul", ["class" => "list-group mb-3"], $progressSidebar, false) ], false), @@ -968,7 +968,7 @@ namespace Documents\Install { ], false) ], false), - + ], false), false ); diff --git a/Core/Documents/Security.class.php b/Core/Documents/Security.class.php index 09a724e..a5b5e65 100644 --- a/Core/Documents/Security.class.php +++ b/Core/Documents/Security.class.php @@ -28,7 +28,7 @@ class Security extends Document { $sql = $this->getContext()->getSQL(); $settings = $this->getSettings(); - $mailSettings = Settings::getAll($sql, "^mail_"); + $gpgKey = $settings->getContactGPGKey(); 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"); $baseUrl = $settings->getBaseUrl(); - $gpgKey = null; + // $gpgKey = null; $lines = [ "# 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"])) { - $lines[] = "Contact: " . $mailSettings["mail_contact"]; + $contactAddress = $settings->getContactMail(); + if (!empty($contactAddress)) { + $lines[] = "Contact: " . $contactAddress; + } - if (isset($mailSettings["mail_contact_gpg_key_id"])) { - $gpgKey = GpgKey::find($sql, $mailSettings["mail_contact_gpg_key_id"]); - if ($gpgKey) { - $lines[] = "Encryption: $baseUrl/.well-known/gpg-key.txt"; - } - } + if ($gpgKey !== null) { + $lines[] = "Encryption: $baseUrl/.well-known/gpg-key.txt"; } $code = implode("\n", $lines); - if ($gpgKey !== null) { $res = GpgKey::sign($code, $gpgKey->getFingerprint()); if ($res["success"]) { @@ -75,17 +72,14 @@ class Security extends Document { return $code; } else if ($activeRoute->getPattern() === "/.well-known/gpg-key.txt") { - - if (isset($mailSettings["mail_contact_gpg_key_id"])) { - $gpgKey = GpgKey::find($sql, $mailSettings["mail_contact_gpg_key_id"]); - if ($gpgKey !== null) { + if ($gpgKey !== null) { + $res = $gpgKey->_export(true); + if ($res["success"]) { header("Content-Type: text/plain"); - $res = $gpgKey->_export(true); - if ($res["success"]) { - return $res["data"]; - } else { - return "Error exporting public key: " . $res["msg"]; - } + return $res["data"]; + } else { + http_response_code(500); + return "Error exporting public key: " . $res["msg"]; } } else { http_response_code(412); diff --git a/Core/Elements/TemplateDocument.class.php b/Core/Elements/TemplateDocument.class.php index 5e7b78f..38c4100 100644 --- a/Core/Elements/TemplateDocument.class.php +++ b/Core/Elements/TemplateDocument.class.php @@ -100,7 +100,7 @@ class TemplateDocument extends Document { "query" => $urlParts["query"] ?? "", "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(), "title" => $this->title, "captcha" => [ diff --git a/Core/Localization/de_DE/account.php b/Core/Localization/de_DE/account.php index 5e7174d..a377314 100644 --- a/Core/Localization/de_DE/account.php +++ b/Core/Localization/de_DE/account.php @@ -91,6 +91,11 @@ return [ "add_group_member_title" => "Mitglied hinzufügen", "add_group_member_text" => "Einen Benutzer suchen um ihn der Gruppe hinzuzufügen", "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_placeholder_text" => "GPG-Key im ASCII format reinziehen oder einfügen...", diff --git a/Core/Localization/de_DE/admin.php b/Core/Localization/de_DE/admin.php index 5861301..6232a7a 100644 --- a/Core/Localization/de_DE/admin.php +++ b/Core/Localization/de_DE/admin.php @@ -17,6 +17,7 @@ return [ "available_groups" => "verfügbare Gruppen", "routes_defined" => "Routen definiert", "error_count" => "Fehler in den letzten 48 Stunden", + "more_info" => "Mehr Infos", # Dialogs "fetch_stats_error" => "Fehler beim Holen der Stats", diff --git a/Core/Localization/de_DE/general.php b/Core/Localization/de_DE/general.php index a09daff..cbc264f 100644 --- a/Core/Localization/de_DE/general.php +++ b/Core/Localization/de_DE/general.php @@ -59,6 +59,7 @@ return [ "choose_file" => "Datei auswählen", "download" => "Herunterladen", "download_all" => "Alles Herunterladen", + "upload_file" => "Datei hochladen", "upload" => "Hochladen", "uploading" => "Lade hoch", "overwrite" => "Überschreiben", diff --git a/Core/Localization/de_DE/settings.php b/Core/Localization/de_DE/settings.php index a286569..d46d1e5 100644 --- a/Core/Localization/de_DE/settings.php +++ b/Core/Localization/de_DE/settings.php @@ -24,11 +24,14 @@ return [ # general settings "site_name" => "Seitenname", + "mail_contact" => "Kontakt E-Mailadresse", "base_url" => "Basis URL", "user_registration_enabled" => "Benutzerregistrierung erlauben", "allowed_extensions" => "Erlaubte Dateierweiterungen", "trusted_domains" => "Vertraute Ursprungs-Domains (* als Subdomain-Wildcard)", "time_zone" => "Zeitzone", + "mail_contact_gpg_key" => "Kontakt GPG-Schlüssel", + "no_gpg_key_configured" => "Noch kein GPG-Schlüssel konfiguriert", # mail settings "mail_enabled" => "E-Mail Versand aktiviert", @@ -65,4 +68,8 @@ return [ "save_settings_error" => "Fehler beim Speichern der Einstellungen", "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!", + "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", ]; \ No newline at end of file diff --git a/Core/Localization/en_US/account.php b/Core/Localization/en_US/account.php index 989668a..a56deb0 100644 --- a/Core/Localization/en_US/account.php +++ b/Core/Localization/en_US/account.php @@ -92,6 +92,11 @@ return [ "remove_group_member_text" => "Do you really want to remove user '%s' from this group?", "add_group_member_title" => "Add member", "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", diff --git a/Core/Localization/en_US/admin.php b/Core/Localization/en_US/admin.php index ad8b341..65b970e 100644 --- a/Core/Localization/en_US/admin.php +++ b/Core/Localization/en_US/admin.php @@ -17,6 +17,7 @@ return [ "available_groups" => "available Groups", "routes_defined" => "Routes defined", "error_count" => "Errors in the past 48 hours", + "more_info" => "More Info", # Dialogs "fetch_stats_error" => "Error fetching stats", diff --git a/Core/Localization/en_US/general.php b/Core/Localization/en_US/general.php index d26f23e..922b6a9 100644 --- a/Core/Localization/en_US/general.php +++ b/Core/Localization/en_US/general.php @@ -54,10 +54,11 @@ return [ "sending" => "Sending", # file - "choose_file" => "Choose File", + "choose_file" => "Choose file", "download" => "Download", "download_all" => "Download All", "upload" => "Upload", + "upload_file" => "Upload file", "uploading" => "Uploading", "rename" => "Rename", "move" => "Move", diff --git a/Core/Localization/en_US/settings.php b/Core/Localization/en_US/settings.php index 3cf055d..94b5a3e 100644 --- a/Core/Localization/en_US/settings.php +++ b/Core/Localization/en_US/settings.php @@ -24,11 +24,14 @@ return [ # general settings "site_name" => "Site Name", + "mail_contact" => "Contact mail address", "base_url" => "Base URL", "user_registration_enabled" => "Allow user registration", "allowed_extensions" => "Allowed file extensions", "trusted_domains" => "Trusted origin domains (* as subdomain-wildcard)", "time_zone" => "Time zone", + "mail_contact_gpg_key" => "Contact GPG key", + "no_gpg_key_configured" => "No GPG key configured yet", # mail settings "mail_enabled" => "Enable e-mail transport", @@ -65,4 +68,8 @@ return [ "save_settings_error" => "Error saving settings", "send_test_email_error" => "Error sending test email", "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", ]; \ No newline at end of file diff --git a/Core/Objects/DatabaseEntity/GpgKey.class.php b/Core/Objects/DatabaseEntity/GpgKey.class.php index 3af6609..cc4e77c 100644 --- a/Core/Objects/DatabaseEntity/GpgKey.class.php +++ b/Core/Objects/DatabaseEntity/GpgKey.class.php @@ -18,9 +18,9 @@ class GpgKey extends DatabaseEntity { private \DateTime $expires; #[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(); - $this->confirmed = false; + $this->confirmed = $confirmed; $this->fingerprint = $fingerprint; $this->algorithm = $algorithm; $this->expires = $expires; diff --git a/Core/core.php b/Core/core.php index bc9cb07..46e1679 100644 --- a/Core/core.php +++ b/Core/core.php @@ -10,7 +10,7 @@ if (is_file($autoLoad)) { require_once $autoLoad; } -const WEBBASE_VERSION = "2.4.0"; +const WEBBASE_VERSION = "2.4.1"; spl_autoload_extensions(".php"); spl_autoload_register(function ($class) { diff --git a/cli.php b/cli.php index 7885650..013a823 100755 --- a/cli.php +++ b/cli.php @@ -96,8 +96,57 @@ function handleDatabase(array $argv): void { $action = $argv[2] ?? ""; if ($action === "migrate") { + $fileName = $argv[3] ?? ""; + if (empty($fileName)) { + _exit("Usage: cli.php db migrate "); + } + + $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(); - _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"])) { // database config @@ -959,7 +1008,7 @@ class $apiName extends Request { $argv = $_SERVER['argv']; $registeredCommands = [ "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], "maintenance" => ["handler" => "onMaintenance", "description" => "toggle maintenance mode", "requiresDocker" => true], "test" => ["handler" => "onTest", "description" => "run unit and integration tests", "requiresDocker" => true], diff --git a/react/admin-panel/src/elements/hidden-file-upload.js b/react/admin-panel/src/elements/hidden-file-upload.js new file mode 100644 index 0000000..1ba48cc --- /dev/null +++ b/react/admin-panel/src/elements/hidden-file-upload.js @@ -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; \ No newline at end of file diff --git a/react/admin-panel/src/views/overview.js b/react/admin-panel/src/views/overview.js index dd6c9c1..7006ac7 100644 --- a/react/admin-panel/src/views/overview.js +++ b/react/admin-panel/src/views/overview.js @@ -39,6 +39,12 @@ const StyledStatBox = styled(Alert)((props) => ({ }, "& div:nth-of-type(1)": { padding: props.theme.spacing(2), + "& span": { + fontSize: "2.5em", + }, + "& p": { + fontSize: "1em", + } }, "& div:nth-of-type(2) > svg": { position: "absolute", @@ -49,8 +55,18 @@ const StyledStatBox = styled(Alert)((props) => ({ }, "& div:nth-of-type(3)": { backdropFilter: "brightness(70%)", - textAlign: "right", 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) => {!isNaN(props.count) ? <> -

{props.count}

+ {props.count}

{props.text}

: } @@ -68,7 +84,8 @@ const StatBox = (props) => {props.icon} - More info + {props.L("admin.more_info")} + @@ -131,25 +148,25 @@ export default function Overview(props) { } - link={"/admin/users"}/> + link={"/admin/users"} L={L}/> } - link={"/admin/groups"}/> + link={"/admin/groups"} L={L}/> } - link={"/admin/routes"}/> + link={"/admin/routes"} L={L}/> } - link={"/admin/logs"}/> + link={"/admin/logs"} L={L}/> diff --git a/react/admin-panel/src/views/profile/gpg-box.js b/react/admin-panel/src/views/profile/gpg-box.js index 9a3462c..b921279 100644 --- a/react/admin-panel/src/views/profile/gpg-box.js +++ b/react/admin-panel/src/views/profile/gpg-box.js @@ -5,6 +5,7 @@ import {CheckCircle, CloudUpload, ErrorOutline, Remove, Upload, VpnKey} from "@m import SpacedFormGroup from "../../elements/form-group"; import ButtonBar from "../../elements/button-bar"; import CollapseBox from "./collapse-box"; +import VisuallyHiddenInput from "../../elements/hidden-file-upload"; const GpgKeyField = styled(TextField)((props) => ({ "& > 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) { // meta @@ -87,7 +76,7 @@ export default function GpgBox(props) { data += event.target.result; if (reader.readyState === 2) { 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; } else { callback(data); @@ -98,9 +87,7 @@ export default function GpgBox(props) { reader.readAsText(file); }, [showDialog]); - return }> + return } {...other}> { profile.gpgKey ? @@ -150,8 +137,8 @@ export default function GpgBox(props) { variant="outlined" startIcon={} component={"label"}> - Upload file - { + {L("general.upload_file")} + { let file = e.target.files[0]; getFileContents(file, (data) => { setGpgKey(data); diff --git a/react/admin-panel/src/views/settings/input-gpg-key.js b/react/admin-panel/src/views/settings/input-gpg-key.js new file mode 100644 index 0000000..4342163 --- /dev/null +++ b/react/admin-panel/src/views/settings/input-gpg-key.js @@ -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 + + { isConfigured ? : } + + { + let file = e.target.files[0]; + getFileContents(file, (data) => { + onImportGPG(data); + }); + return false; + }} /> + + +} \ No newline at end of file diff --git a/react/admin-panel/src/views/settings/settings.js b/react/admin-panel/src/views/settings/settings.js index 3cf5054..acf7d45 100644 --- a/react/admin-panel/src/views/settings/settings.js +++ b/react/admin-panel/src/views/settings/settings.js @@ -3,7 +3,7 @@ import {LocaleContext} from "shared/locale"; import { Box, Button, CircularProgress, FormControl, - FormGroup, FormLabel, Grid, IconButton, + FormLabel, Grid, IconButton, Paper, Tab, Table, @@ -23,7 +23,7 @@ import { RestartAlt, Save, Send, - SettingsApplications, SmartToy, Storage + SettingsApplications, SmartToy, Storage, } from "@mui/icons-material"; import TIME_ZONES from "shared/time-zones"; import ButtonBar from "../../elements/button-bar"; @@ -34,10 +34,12 @@ import SettingsPasswordInput from "./input-password"; import SettingsTextInput from "./input-text"; import SettingsSelection from "./input-selection"; import ViewContent from "../../elements/view-content"; +import GpgKeyInput from "./input-gpg-key"; +import SpacedFormGroup from "../../elements/form-group"; export default function SettingsView(props) { - // TODO: website-logo (?), mail_contact, mail_contact_gpg_key_id + // TODO: website-logo (?), mail_contact_gpg_key_id // meta const api = props.api; @@ -47,6 +49,7 @@ export default function SettingsView(props) { "general": [ "base_url", "site_name", + "mail_contact", "user_registration_enabled", "time_zone", "allowed_extensions", @@ -75,6 +78,8 @@ export default function SettingsView(props) { ] }; + const CUSTOM_KEYS = ["mail_contact_gpg_key"]; + // data const [fetchSettings, setFetchSettings] = useState(true); const [settings, setSettings] = useState(null); @@ -94,8 +99,12 @@ export default function SettingsView(props) { }, [])).includes(key); } + const isCustom = (key) => { + return CUSTOM_KEYS.includes(key); + } + useEffect(() => { - requestModules(props.api, ["general", "settings"], currentLocale).then(data => { + requestModules(props.api, ["general", "settings", "account"], currentLocale).then(data => { if (!data.success) { showDialog("Error fetching translations: " + data.msg); } @@ -115,7 +124,9 @@ export default function SettingsView(props) { 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(() => { 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); if (data.success) { showDialog(L("settings.save_settings_success"), L("general.success")); @@ -253,6 +272,13 @@ export default function SettingsView(props) { if (selectedTab === "general") { return [ renderTextInput("site_name"), + renderTextInput("mail_contact", false, {type: "email"}), + + {L("settings.mail_contact_gpg_key")} + setSettings({...settings, mail_contact_gpg_key: value})}/> + , renderTextInput("base_url"), renderTextValuesInput("trusted_domains"), renderCheckBox("user_registration_enabled"), @@ -269,7 +295,7 @@ export default function SettingsView(props) { renderPasswordInput("mail_password", !settings.mail_enabled), renderTextInput("mail_footer", !settings.mail_enabled), renderCheckBox("mail_async", !settings.mail_enabled), - + {L("settings.send_test_email")} @@ -292,7 +318,7 @@ export default function SettingsView(props) { - + ]; } else if (selectedTab === "captcha") { let captchaOptions = {}; diff --git a/react/admin-panel/src/views/user/user-edit.js b/react/admin-panel/src/views/user/user-edit.js index 520858a..be79548 100644 --- a/react/admin-panel/src/views/user/user-edit.js +++ b/react/admin-panel/src/views/user/user-edit.js @@ -16,7 +16,7 @@ import * as React from "react"; import ViewContent from "../../elements/view-content"; import FormGroup from "../../elements/form-group"; 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"; const initialUser = { @@ -51,20 +51,20 @@ export default function UserEditView(props) { const [sendInvite, setSetInvite] = useState(isNewUser); useEffect(() => { - requestModules(props.api, ["general", "account"], currentLocale).then(data => { + requestModules(api, ["general", "account"], currentLocale).then(data => { if (!data.success) { - props.showDialog("Error fetching translations: " + data.msg); + showDialog("Error fetching translations: " + data.msg); } }); }, [currentLocale]); const onFetchGroups = useCallback(() => { api.searchGroups(groupInput, user?.groups?.map(group => group.id)).then((res) => { - if (res.success) { - setGroups(res.groups); - } else { - showDialog(res.msg, L("account.search_groups_error")); - } + if (res.success) { + setGroups(res.groups); + } else { + showDialog(res.msg, L("account.search_groups_error")); + } }); }, [api, showDialog, user?.groups, groupInput]); @@ -110,7 +110,7 @@ export default function UserEditView(props) { }); } else { api.createUser(user.name, user.fullName, user.email, groupIds, - user.password, user.passwordConfirm + user.password, user.passwordConfirm ).then(res => { setSaving(false); if (res.success) { @@ -143,6 +143,16 @@ export default function UserEditView(props) { setChanged(true); }, [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(() => { if (!isNewUser) { onFetchUser(true); @@ -163,118 +173,132 @@ export default function UserEditView(props) { {isNewUser ? L("general.new") : L("general.edit")} ]}> + + + - - {L("account.name")} - - onChangeValue("name", e.target.value)} /> - - - - {L("account.full_name")} - - onChangeValue("fullName", e.target.value)} /> - - - - {L("account.email")} - - onChangeValue("email", e.target.value)} /> - - - - {L("account.groups")} - group.name} - getOptionKey={group => group.id} - filterOptions={(options) => options} - clearOnBlur={true} - clearOnEscape - freeSolo - multiple - value={user.groups} - inputValue={groupInput} - onChange={(e, v) => onChangeValue("groups", v)} - onInputChange={e => setGroupInput((!e || e.target.value === 0) ? "" : e.target.value) } - renderTags={(values, props) => - values.map((option, index) => { - return - }) - } - renderInput={(params) => setGroupInput("")} />} - /> - - { !isNewUser ? - <> - - {L("account.password")} - - onChangeValue("password", e.target.value)} /> - - - - onChangeValue("active", v)} />} - label={L("account.active")} /> - - - onChangeValue("confirmed", v)} />} - label={L("account.confirmed")} /> - - : <> - - setSetInvite(v)} />} - label={L("account.send_invite")} /> - - {!sendInvite && <> + + {L("account.name")} + + onChangeValue("name", e.target.value)} /> + + + + {L("account.full_name")} + + onChangeValue("fullName", e.target.value)} /> + + + + {L("account.email")} + + onChangeValue("email", e.target.value)} /> + + + + {L("account.groups")} + group.name} + getOptionKey={group => group.id} + filterOptions={(options) => options} + clearOnBlur={true} + clearOnEscape + freeSolo + multiple + value={user.groups} + inputValue={groupInput} + onChange={(e, v) => onChangeValue("groups", v)} + onInputChange={e => setGroupInput((!e || e.target.value === 0) ? "" : e.target.value) } + renderTags={(values, props) => + values.map((option, index) => { + return + }) + } + renderInput={(params) => setGroupInput("")} />} + /> + + { !isNewUser ? + <> {L("account.password")} onChangeValue("password", e.target.value)} /> + + onChangeValue("active", v)} />} + label={L("account.active")} /> + - {L("account.password_confirm")} - - onChangeValue("passwordConfirm", e.target.value)} /> - + onChangeValue("confirmed", v)} />} + label={L("account.confirmed")} /> - - - + : <> + + setSetInvite(v)} />} + label={L("account.send_invite")} /> + + {!sendInvite && <> + + {L("account.password")} + + onChangeValue("password", e.target.value)} /> + + + + {L("account.password_confirm")} + + onChangeValue("passwordConfirm", e.target.value)} /> + + + + + + + } - } - - } + } diff --git a/react/shared/api.js b/react/shared/api.js index 32874d4..9808cf8 100644 --- a/react/shared/api.js +++ b/react/shared/api.js @@ -302,6 +302,14 @@ export default class API { 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 **/ async sendTestMail(receiver) { return this.apiCall("mail/test", { receiver: receiver }); @@ -396,8 +404,8 @@ export default class API { } /** GPG API **/ - async uploadGPG(pubkey) { - let res = await this.apiCall("gpgKey/import", { pubkey: pubkey }); + async uploadGPG(publicKey) { + let res = await this.apiCall("gpgKey/import", { publicKey: publicKey }); if (res.success) { this.user.gpgKey = res.gpgKey; }