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; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| 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; | ||||
|  | ||||
| @ -215,40 +215,17 @@ 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 ($_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."; | ||||
|           $this->lastError = 'Invalid request body.'; | ||||
|           http_response_code(400); | ||||
|           return false; | ||||
|         } | ||||
|       } | ||||
|       } | ||||
| 
 | ||||
|       if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { | ||||
|         http_response_code(204); # No content
 | ||||
| @ -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 { | ||||
|  | ||||
| @ -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]; | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										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 { | ||||
|           $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; | ||||
|  | ||||
							
								
								
									
										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\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; | ||||
|   } | ||||
|  | ||||
| @ -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(); | ||||
|           } | ||||
|  | ||||
| @ -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) { | ||||
|       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,18 +72,15 @@ 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) { | ||||
|           header("Content-Type: text/plain"); | ||||
|         $res = $gpgKey->_export(true); | ||||
|         if ($res["success"]) { | ||||
|           header("Content-Type: text/plain"); | ||||
|           return $res["data"]; | ||||
|         } else { | ||||
|           http_response_code(500); | ||||
|           return "Error exporting public key: " . $res["msg"]; | ||||
|         } | ||||
|         } | ||||
|       } else { | ||||
|         http_response_code(412); | ||||
|         return "No gpg key configured yet."; | ||||
|  | ||||
| @ -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" => [ | ||||
|  | ||||
| @ -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...", | ||||
|  | ||||
| @ -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", | ||||
|  | ||||
| @ -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", | ||||
|  | ||||
| @ -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", | ||||
| ]; | ||||
| @ -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", | ||||
|  | ||||
| @ -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", | ||||
|  | ||||
| @ -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", | ||||
|  | ||||
| @ -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", | ||||
| ]; | ||||
| @ -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; | ||||
|  | ||||
| @ -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) { | ||||
|  | ||||
							
								
								
									
										53
									
								
								cli.php
									
									
									
									
									
								
							
							
								
								
								
								
								
									
									
								
							
						
						
									
										53
									
								
								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 <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(); | ||||
|     _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], | ||||
|  | ||||
							
								
								
									
										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)": { | ||||
|             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) => <StyledStatBox variant={"filled"} icon={false} | ||||
|         <Box> | ||||
|             {!isNaN(props.count) ? | ||||
|                 <> | ||||
|                     <h2>{props.count}</h2> | ||||
|                     <span>{props.count}</span> | ||||
|                     <p>{props.text}</p> | ||||
|                 </> : <CircularProgress variant={"determinate"} /> | ||||
|             } | ||||
| @ -68,7 +84,8 @@ const StatBox = (props) => <StyledStatBox variant={"filled"} icon={false} | ||||
|         <Box>{props.icon}</Box> | ||||
|         <Box> | ||||
|             <Link to={props.link}> | ||||
|                 More info <ArrowCircleRight /> | ||||
|                 <span>{props.L("admin.more_info")}</span> | ||||
|                 <ArrowCircleRight /> | ||||
|             </Link> | ||||
|         </Box> | ||||
|     </StyledStatBox> | ||||
| @ -131,25 +148,25 @@ export default function Overview(props) { | ||||
|                 <StatBox color={"info"} count={stats?.userCount} | ||||
|                          text={L("admin.users_registered")} | ||||
|                          icon={<People/>} | ||||
|                          link={"/admin/users"}/> | ||||
|                          link={"/admin/users"} L={L}/> | ||||
|             </Grid> | ||||
|             <Grid item xs={6} lg={3}> | ||||
|                 <StatBox color={"success"} count={stats?.groupCount} | ||||
|                          text={L("admin.available_groups")} | ||||
|                          icon={<Groups/>} | ||||
|                          link={"/admin/groups"}/> | ||||
|                          link={"/admin/groups"} L={L}/> | ||||
|             </Grid> | ||||
|             <Grid item xs={6} lg={3}> | ||||
|                 <StatBox color={"warning"} count={stats?.pageCount} | ||||
|                          text={L("admin.routes_defined")} | ||||
|                          icon={<LibraryBooks/>} | ||||
|                          link={"/admin/routes"}/> | ||||
|                          link={"/admin/routes"} L={L}/> | ||||
|             </Grid> | ||||
|             <Grid item xs={6} lg={3}> | ||||
|                 <StatBox color={"error"} count={stats?.errorCount} | ||||
|                          text={L("admin.error_count")} | ||||
|                          icon={<BugReport />} | ||||
|                          link={"/admin/logs"}/> | ||||
|                          link={"/admin/logs"} L={L}/> | ||||
|             </Grid> | ||||
|         </Grid> | ||||
|         <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 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 <CollapseBox title={L("account.gpg_key")} {...other} | ||||
| 
 | ||||
|                         icon={<VpnKey />}> | ||||
|     return <CollapseBox title={L("account.gpg_key")} icon={<VpnKey />} {...other}> | ||||
|         { | ||||
|             profile.gpgKey ? <Box> | ||||
|                     <GpgFingerprintBox mb={2}> | ||||
| @ -150,7 +137,7 @@ export default function GpgBox(props) { | ||||
|                                 variant="outlined" | ||||
|                                 startIcon={<CloudUpload />} | ||||
|                                 component={"label"}> | ||||
|                             Upload file | ||||
|                             {L("general.upload_file")} | ||||
|                             <VisuallyHiddenInput type={"file"} onChange={e => { | ||||
|                                 let file = e.target.files[0]; | ||||
|                                 getFileContents(file, (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 { | ||||
|     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"}), | ||||
|                 <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"), | ||||
|                 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), | ||||
|                 <FormGroup key={"mail-test"}> | ||||
|                 <SpacedFormGroup key={"mail-test"}> | ||||
|                     <FormLabel>{L("settings.send_test_email")}</FormLabel> | ||||
|                     <FormControl disabled={!settings.mail_enabled}> | ||||
|                         <Grid container spacing={1}> | ||||
| @ -292,7 +318,7 @@ export default function SettingsView(props) { | ||||
|                             </Grid> | ||||
|                         </Grid> | ||||
|                     </FormControl> | ||||
|                 </FormGroup> | ||||
|                 </SpacedFormGroup> | ||||
|             ]; | ||||
|         } else if (selectedTab === "captcha") { | ||||
|             let captchaOptions = {}; | ||||
|  | ||||
| @ -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,9 +51,9 @@ 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]); | ||||
| @ -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,6 +173,20 @@ export default function UserEditView(props) { | ||||
|         <span key={"action"}>{isNewUser ? L("general.new") : L("general.edit")}</span> | ||||
|     ]}> | ||||
|         <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}> | ||||
|                 <FormGroup> | ||||
|                     <FormLabel>{L("account.name")}</FormLabel> | ||||
|  | ||||
| @ -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; | ||||
|         } | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user