Compare commits

..

10 Commits

53 changed files with 998 additions and 350 deletions

@ -10,7 +10,6 @@ namespace Core\API {
$this->loginRequired = true; $this->loginRequired = true;
} }
} }
} }
namespace Core\API\GpgKey { namespace Core\API\GpgKey {
@ -20,6 +19,7 @@ namespace Core\API\GpgKey {
use Core\API\Parameter\Parameter; use Core\API\Parameter\Parameter;
use Core\API\Parameter\StringType; use Core\API\Parameter\StringType;
use Core\API\Template\Render; use Core\API\Template\Render;
use Core\API\Traits\GpgKeyValidation;
use Core\Driver\SQL\Condition\Compare; use Core\Driver\SQL\Condition\Compare;
use Core\Objects\Context; use Core\Objects\Context;
use Core\Objects\DatabaseEntity\GpgKey; use Core\Objects\DatabaseEntity\GpgKey;
@ -28,36 +28,16 @@ namespace Core\API\GpgKey {
class Import extends GpgKeyAPI { class Import extends GpgKeyAPI {
use GpgKeyValidation;
public function __construct(Context $context, bool $externalCall = false) { public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, [ parent::__construct($context, $externalCall, [
"pubkey" => new StringType("pubkey") "publicKey" => new StringType("publicKey")
]); ]);
$this->loginRequired = true; $this->loginRequired = true;
$this->forbidMethod("GET"); $this->forbidMethod("GET");
} }
private function testKey(string $keyString) {
$res = GpgKey::getKeyInfo($keyString);
if (!$res["success"]) {
return $this->createError($res["error"] ?? $res["msg"]);
}
$keyData = $res["data"];
$keyType = $keyData["type"];
$expires = $keyData["expires"];
if ($keyType === "sec#") {
return self::createError("ATTENTION! It seems like you've imported a PGP PRIVATE KEY instead of a public key.
It is recommended to immediately revoke your private key and create a new key pair.");
} else if ($keyType !== "pub") {
return self::createError("Unknown key type: $keyType");
} else if (isInPast($expires)) {
return self::createError("It seems like the gpg key is already expired.");
} else {
return $keyData;
}
}
public function _execute(): bool { public function _execute(): bool {
$currentUser = $this->context->getUser(); $currentUser = $this->context->getUser();
@ -69,8 +49,7 @@ namespace Core\API\GpgKey {
} }
// fix key first, enforce a newline after // fix key first, enforce a newline after
$keyString = $this->getParam("pubkey"); $keyString = $this->formatKey($this->getParam("publicKey"));
$keyString = preg_replace("/(-{2,})\n([^\n])/", "$1\n\n$2", $keyString);
$keyData = $this->testKey($keyString); $keyData = $this->testKey($keyString);
if ($keyData === false) { if ($keyData === false) {
return false; return false;

@ -215,40 +215,17 @@ abstract class Request {
return false; return false;
} }
if ($this->isMethodAllowed("GET") && $this->isMethodAllowed("POST")) {
$values = $_REQUEST; $values = $_REQUEST;
} else if ($this->isMethodAllowed("POST")) { if ($_SERVER['REQUEST_METHOD'] === 'POST' && in_array("application/json", explode(";", $_SERVER["CONTENT_TYPE"] ?? ""))) {
$values = $_POST; $jsonData = json_decode(file_get_contents('php://input'), true);
} 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) { if ($jsonData !== null) {
$values = array_merge($values, $jsonData); $values = array_merge($values, $jsonData);
} else { } else {
$this->lastError = "Invalid request body."; $this->lastError = 'Invalid request body.';
http_response_code(400); http_response_code(400);
return false; return false;
} }
} }
}
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204); # No content http_response_code(204); # No content
@ -362,7 +339,8 @@ abstract class Request {
$obj = $this->params; $obj = $this->params;
} }
return $obj[$name]?->value; // I don't know why phpstorm
return (isset($obj[$name]) ? $obj[$name]->value : NULL);
} }
public function isMethodAllowed(string $method): bool { public function isMethodAllowed(string $method): bool {

@ -19,6 +19,7 @@ namespace Core\API {
// API parameters should be more configurable, e.g. allow regexes, min/max values for numbers, etc. // API parameters should be more configurable, e.g. allow regexes, min/max values for numbers, etc.
$this->predefinedKeys = [ $this->predefinedKeys = [
"allowed_extensions" => new ArrayType("allowed_extensions", Parameter::TYPE_STRING), "allowed_extensions" => new ArrayType("allowed_extensions", Parameter::TYPE_STRING),
"mail_contact" => new Parameter("mail_contact", Parameter::TYPE_EMAIL, true, ""),
"trusted_domains" => new ArrayType("trusted_domains", Parameter::TYPE_STRING), "trusted_domains" => new ArrayType("trusted_domains", Parameter::TYPE_STRING),
"user_registration_enabled" => new Parameter("user_registration_enabled", Parameter::TYPE_BOOLEAN), "user_registration_enabled" => new Parameter("user_registration_enabled", Parameter::TYPE_BOOLEAN),
"captcha_provider" => new StringType("captcha_provider", -1, true, "disabled", CaptchaProvider::PROVIDERS), "captcha_provider" => new StringType("captcha_provider", -1, true, "disabled", CaptchaProvider::PROVIDERS),
@ -38,20 +39,25 @@ namespace Core\API\Settings {
use Core\API\Parameter\RegexType; use Core\API\Parameter\RegexType;
use Core\API\Parameter\StringType; use Core\API\Parameter\StringType;
use Core\API\SettingsAPI; use Core\API\SettingsAPI;
use Core\API\Traits\GpgKeyValidation;
use Core\Configuration\Settings; use Core\Configuration\Settings;
use Core\Driver\SQL\Column\Column; use Core\Driver\SQL\Column\Column;
use Core\Driver\SQL\Condition\CondBool; use Core\Driver\SQL\Condition\CondBool;
use Core\Driver\SQL\Condition\CondIn; use Core\Driver\SQL\Condition\CondIn;
use Core\Driver\SQL\Strategy\UpdateStrategy; use Core\Driver\SQL\Strategy\UpdateStrategy;
use Core\Objects\Context; use Core\Objects\Context;
use Core\Objects\DatabaseEntity\GpgKey;
use Core\Objects\DatabaseEntity\Group; use Core\Objects\DatabaseEntity\Group;
class Get extends SettingsAPI { class Get extends SettingsAPI {
private ?GpgKey $contactGpgKey;
public function __construct(Context $context, bool $externalCall = false) { public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, array( parent::__construct($context, $externalCall, array(
'key' => new StringType('key', -1, true, NULL) 'key' => new StringType('key', -1, true, NULL)
)); ));
$this->contactGpgKey = null;
} }
public function _execute(): bool { public function _execute(): bool {
@ -61,6 +67,12 @@ namespace Core\API\Settings {
$settings = Settings::getAll($sql, $key, $this->isExternalCall()); $settings = Settings::getAll($sql, $key, $this->isExternalCall());
if ($settings !== null) { if ($settings !== null) {
$this->result["settings"] = $settings; $this->result["settings"] = $settings;
// TODO: improve this custom key
$gpgKeyId = $this->result["settings"]["mail_contact_gpg_key_id"] ?? null;
$this->contactGpgKey = $gpgKeyId === null ? null : GpgKey::find($sql, $gpgKeyId);
unset($this->result["settings"]["mail_contact_gpg_key_id"]);
$this->result["settings"]["mail_contact_gpg_key"] = $this->contactGpgKey?->jsonSerialize();
} else { } else {
return $this->createError("Error fetching settings: " . $sql->getLastError()); return $this->createError("Error fetching settings: " . $sql->getLastError());
} }
@ -68,6 +80,10 @@ namespace Core\API\Settings {
return $this->success; return $this->success;
} }
public function getContactGpgKey(): ?GpgKey {
return $this->contactGpgKey;
}
public static function getDescription(): string { public static function getDescription(): string {
return "Allows users to fetch site settings"; return "Allows users to fetch site settings";
} }
@ -138,7 +154,6 @@ namespace Core\API\Settings {
["value" => new Column("value")]) ["value" => new Column("value")])
); );
$this->success = ($query->execute() !== FALSE); $this->success = ($query->execute() !== FALSE);
$this->lastError = $sql->getLastError(); $this->lastError = $sql->getLastError();
@ -188,4 +203,90 @@ namespace Core\API\Settings {
return [Group::ADMIN]; return [Group::ADMIN];
} }
} }
class ImportGPG extends SettingsAPI {
use GpgKeyValidation;
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, [
"publicKey" => new StringType("publicKey")
]);
$this->forbidMethod("GET");
}
protected function _execute(): bool {
$sql = $this->context->getSQL();
// fix key first, enforce a newline after
$keyString = $this->formatKey($this->getParam("publicKey"));
$keyData = $this->testKey($keyString, null);
if ($keyData === false) {
return false;
}
$res = GpgKey::importKey($keyString);
if (!$res["success"]) {
return $this->createError($res["error"]);
}
// we will auto-confirm this key
$sql = $this->context->getSQL();
$gpgKey = new GpgKey($keyData["fingerprint"], $keyData["algorithm"], $keyData["expires"], true);
if (!$gpgKey->save($sql)) {
return $this->createError("Error creating gpg key: " . $sql->getLastError());
}
$this->success = $sql->insert("Settings", ["name", "value", "private", "readonly"])
->addRow("mail_contact_gpg_key_id", $gpgKey->getId(), false, true)
->onDuplicateKeyStrategy(new UpdateStrategy(
["name"],
["value" => new Column("value")])
)->execute() !== false;
$this->lastError = $sql->getLastError();
$this->result["gpgKey"] = $gpgKey->jsonSerialize();
return $this->success;
}
public static function getDescription(): string {
return "Allows administrators to import a GPG-key to use it as a contact key.";
}
public static function getDefaultPermittedGroups(): array {
return [Group::ADMIN];
}
}
class RemoveGPG extends SettingsAPI {
public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall);
}
protected function _execute(): bool {
$sql = $this->context->getSQL();
$settings = $this->context->getSettings();
$gpgKey = $settings->getContactGPGKey();
if ($gpgKey === null) {
return $this->createError("No GPG-Key configured yet");
}
$this->success = $sql->update("Settings")
->set("value", NULL)
->whereEq("name", "mail_contact_gpg_key_id")
->execute() !== false;
$this->lastError = $sql->getLastError();
return $this->success;
}
public static function getDescription(): string {
return "Allows administrators to remove the GPG-key used as a contact key.";
}
public static function getDefaultPermittedGroups(): array {
return [Group::ADMIN];
}
}
} }

@ -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);
}
}

@ -237,7 +237,7 @@ namespace Core\API\User {
public function __construct(Context $context, $externalCall = false) { public function __construct(Context $context, $externalCall = false) {
parent::__construct($context, $externalCall, parent::__construct($context, $externalCall,
self::getPaginationParameters(['id', 'name', 'fullName', 'email', 'groups', 'registeredAt', 'active', 'confirmed'], self::getPaginationParameters(['id', 'name', 'fullName', 'email', 'groups', 'lastOnline', 'registeredAt', 'active', 'confirmed'],
'id', 'asc') 'id', 'asc')
); );
} }
@ -316,20 +316,17 @@ namespace Core\API\User {
} else if ($user === null) { } else if ($user === null) {
return $this->createError("User not found"); return $this->createError("User not found");
} else { } else {
// allow access to unconfirmed users only when we have administrative privileges, or we are querying ourselves
$queriedUser = $user->jsonSerialize();
$currentUser = $this->context->getUser(); $currentUser = $this->context->getUser();
// full info only when we have administrative privileges, or we are querying ourselves
$fullInfo = ($userId === $currentUser->getId() || $fullInfo = ($userId === $currentUser->getId() ||
$currentUser->hasGroup(Group::ADMIN) || $currentUser->hasGroup(Group::ADMIN) ||
$currentUser->hasGroup(Group::SUPPORT)); $currentUser->hasGroup(Group::SUPPORT));
if (!$fullInfo && !$queriedUser["confirmed"]) { if (!$fullInfo && !$user->isConfirmed()) {
return $this->createError("No permissions to access this user"); return $this->createError("No permissions to access this user");
} }
$this->result["user"] = $queriedUser; $this->result["user"] = $user->jsonSerialize();
} }
return $this->success; return $this->success;
@ -1008,7 +1005,15 @@ namespace Core\API\User {
} else { } else {
$this->success = ($user->delete($sql) !== FALSE); $this->success = ($user->delete($sql) !== FALSE);
$this->lastError = $sql->getLastError(); $this->lastError = $sql->getLastError();
$this->logger->info(sprintf(
"User '%s' (id=%d) deleted by %s",
$user->getDisplayName(),
$id,
$this->logUserId())
);
} }
} else {
$this->lastError = $sql->getLastError();
} }
return $this->success; return $this->success;

@ -3,12 +3,24 @@
namespace Core\Configuration; namespace Core\Configuration;
use Core\API\Request; use Core\API\Request;
use Core\Driver\Logger\Logger;
use Core\Driver\SQL\Query\CreateTable;
use Core\Driver\SQL\SQL; use Core\Driver\SQL\SQL;
use Core\Objects\DatabaseEntity\Controller\DatabaseEntity; use Core\Objects\DatabaseEntity\Controller\DatabaseEntity;
use PHPUnit\Util\Exception; use PHPUnit\Util\Exception;
class CreateDatabase { class CreateDatabase {
private static ?Logger $logger = null;
public static function getLogger(SQL $sql): Logger {
if (self::$logger === null) {
self::$logger = new Logger("CreateDatabase", $sql);
}
return self::$logger;
}
public static function createQueries(SQL $sql): array { public static function createQueries(SQL $sql): array {
$queries = array(); $queries = array();
@ -51,35 +63,54 @@ class CreateDatabase {
} }
} }
private static function loadEntities(SQL $sql, array &$queries): void { private static function getCreatedTables(SQL $sql, array $queries): ?array {
$createdTables = $sql->listTables();
if ($createdTables !== null) {
foreach ($queries as $query) {
if ($query instanceof CreateTable) {
$tableName = $query->getTableName();
if (!in_array($tableName, $createdTables)) {
$createdTables[] = $tableName;
}
}
}
} else {
self::getLogger($sql)->warning("Error querying existing tables: " . $sql->getLastError());
}
return $createdTables;
}
public static function createEntityQueries(SQL $sql, array $entityClasses, array &$queries, bool $skipExisting = false): void {
if (empty($entityClasses)) {
return;
}
// first, check what tables are already created
$createdTables = self::getCreatedTables($sql, $queries);
if ($createdTables === null) {
throw new \Exception("Error querying existing tables");
}
// then collect all persistable entities (tables, relations, etc.)
$persistables = []; $persistables = [];
$baseDirs = ["Core", "Site"]; foreach ($entityClasses as $className) {
foreach ($baseDirs as $baseDir) {
$entityDirectory = "./$baseDir/Objects/DatabaseEntity/";
if (file_exists($entityDirectory) && is_dir($entityDirectory)) {
$scan_arr = scandir($entityDirectory);
$files_arr = array_diff($scan_arr, array('.', '..'));
foreach ($files_arr as $file) {
$suffix = ".class.php";
if (endsWith($file, $suffix)) {
$className = substr($file, 0, strlen($file) - strlen($suffix));
$className = "\\$baseDir\\Objects\\DatabaseEntity\\$className";
$reflectionClass = new \ReflectionClass($className); $reflectionClass = new \ReflectionClass($className);
if ($reflectionClass->isSubclassOf(DatabaseEntity::class)) { if ($reflectionClass->isSubclassOf(DatabaseEntity::class)) {
$method = "$className::getHandler"; $handler = ("$className::getHandler")($sql, null, true);
$handler = call_user_func($method, $sql, null, true);
$persistables[$handler->getTableName()] = $handler; $persistables[$handler->getTableName()] = $handler;
foreach ($handler->getNMRelations() as $nmTableName => $nmRelation) { foreach ($handler->getNMRelations() as $nmTableName => $nmRelation) {
$persistables[$nmTableName] = $nmRelation; $persistables[$nmTableName] = $nmRelation;
} }
} } else {
} throw new \Exception("Class '$className' is not a subclass of DatabaseEntity");
}
} }
} }
// now order the persistable entities so all dependencies are met.
$tableCount = count($persistables); $tableCount = count($persistables);
$createdTables = [];
while (!empty($persistables)) { while (!empty($persistables)) {
$prevCount = $tableCount; $prevCount = $tableCount;
$unmetDependenciesTotal = []; $unmetDependenciesTotal = [];
@ -88,7 +119,7 @@ class CreateDatabase {
$dependsOn = $persistable->dependsOn(); $dependsOn = $persistable->dependsOn();
$unmetDependencies = array_diff($dependsOn, $createdTables); $unmetDependencies = array_diff($dependsOn, $createdTables);
if (empty($unmetDependencies)) { if (empty($unmetDependencies)) {
$queries = array_merge($queries, $persistable->getCreateQueries($sql)); $queries = array_merge($queries, $persistable->getCreateQueries($sql, $skipExisting));
$createdTables[] = $tableName; $createdTables[] = $tableName;
unset($persistables[$tableName]); unset($persistables[$tableName]);
} else { } else {
@ -104,6 +135,32 @@ class CreateDatabase {
} }
} }
private static function loadEntities(SQL $sql, array &$queries): void {
$classes = [];
$baseDirs = ["Core", "Site"];
foreach ($baseDirs as $baseDir) {
$entityDirectory = "./$baseDir/Objects/DatabaseEntity/";
if (file_exists($entityDirectory) && is_dir($entityDirectory)) {
$scan_arr = scandir($entityDirectory);
$files_arr = array_diff($scan_arr, [".", ".."]);
foreach ($files_arr as $file) {
$suffix = ".class.php";
if (endsWith($file, $suffix)) {
$className = substr($file, 0, strlen($file) - strlen($suffix));
$className = "\\$baseDir\\Objects\\DatabaseEntity\\$className";
$reflectionClass = new \ReflectionClass($className);
if ($reflectionClass->isSubclassOf(DatabaseEntity::class)) {
$classes[] = $className;
}
}
}
}
}
self::createEntityQueries($sql, $classes, $queries);
}
public static function loadDefaultACL(SQL $sql, array &$queries): void { public static function loadDefaultACL(SQL $sql, array &$queries): void {
$query = $sql->insert("ApiPermission", ["method", "groups", "description", "is_core"]); $query = $sql->insert("ApiPermission", ["method", "groups", "description", "is_core"]);

@ -0,0 +1,25 @@
<?php
use Core\Driver\SQL\Column\Column;
use Core\Driver\SQL\Strategy\UpdateStrategy;
use Core\Objects\DatabaseEntity\Group;
$queries[] = $sql->insert("Settings", ["name", "value", "private", "readonly"])
->onDuplicateKeyStrategy(new UpdateStrategy(
["name"],
["name" => new Column("name")])
)
->addRow("mail_contact_gpg_key_id", null, false, true)
->addRow("mail_contact", "''", false, false);
$queries[] = $sql->insert("ApiPermission", ["method", "groups", "description", "is_core"])
->onDuplicateKeyStrategy(new UpdateStrategy(
["method"],
["method" => new Column("method")])
)
->addRow("settings/importGPG",
json_encode(\Core\API\Settings\ImportGPG::getDefaultPermittedGroups()),
\Core\API\Settings\ImportGPG::getDescription(), true)
->addRow("settings/removeGPG",
json_encode(\Core\API\Settings\RemoveGPG::getDefaultPermittedGroups()),
\Core\API\Settings\RemoveGPG::getDescription(), true);

@ -17,6 +17,7 @@ use Core\Objects\Captcha\GoogleRecaptchaProvider;
use Core\Objects\Captcha\HCaptchaProvider; use Core\Objects\Captcha\HCaptchaProvider;
use Core\Objects\ConnectionData; use Core\Objects\ConnectionData;
use Core\Objects\Context; use Core\Objects\Context;
use Core\Objects\DatabaseEntity\GpgKey;
class Settings { class Settings {
@ -25,6 +26,11 @@ class Settings {
// general settings // general settings
private string $siteName; private string $siteName;
private string $contactMail;
private ?GpgKey $contactGpgKey;
private string $baseUrl; private string $baseUrl;
private array $trustedDomains; private array $trustedDomains;
private bool $registrationAllowed; private bool $registrationAllowed;
@ -56,7 +62,14 @@ class Settings {
} }
public static function getAll(?SQL $sql, ?string $pattern = null, bool $external = false): ?array { public static function getAll(?SQL $sql, ?string $pattern = null, bool $external = false): ?array {
$query = $sql->select("name", "value")->from("Settings");
// We do not have a Settings table yet, we might still be in installation phase
if (!$sql->tableExists("Settings")) {
return null;
}
$query = $sql->select("name", "value")
->from("Settings");
if ($pattern) { if ($pattern) {
$query->where(new CondRegex(new Column("name"), $pattern)); $query->where(new CondRegex(new Column("name"), $pattern));
@ -101,6 +114,8 @@ class Settings {
// General // General
$settings->siteName = "WebBase"; $settings->siteName = "WebBase";
$settings->contactMail = "webmaster@$hostname";
$settings->contactGpgKey = null;
$settings->baseUrl = "$protocol://$hostname"; $settings->baseUrl = "$protocol://$hostname";
$settings->trustedDomains = [$hostname]; $settings->trustedDomains = [$hostname];
$settings->allowedExtensions = ['png', 'jpg', 'jpeg', 'gif', 'htm', 'html']; $settings->allowedExtensions = ['png', 'jpg', 'jpeg', 'gif', 'htm', 'html'];
@ -137,13 +152,15 @@ class Settings {
} }
public function loadFromDatabase(Context $context): bool { public function loadFromDatabase(Context $context): bool {
$this->logger = new Logger("Settings", $context->getSQL()); $sql = $context->getSQL();
$this->logger = new Logger("Settings", $sql);
$req = new \Core\API\Settings\Get($context); $req = new \Core\API\Settings\Get($context);
$success = $req->execute(); $success = $req->execute();
if ($success) { if ($success) {
$result = $req->getResult()["settings"]; $result = $req->getResult()["settings"];
$this->siteName = $result["site_name"] ?? $this->siteName; $this->siteName = $result["site_name"] ?? $this->siteName;
$this->contactMail = $result["mail_contact"] ?? $this->contactMail;
$this->baseUrl = $result["base_url"] ?? $this->baseUrl; $this->baseUrl = $result["base_url"] ?? $this->baseUrl;
$this->registrationAllowed = $result["user_registration_enabled"] ?? $this->registrationAllowed; $this->registrationAllowed = $result["user_registration_enabled"] ?? $this->registrationAllowed;
$this->installationComplete = $result["installation_completed"] ?? $this->installationComplete; $this->installationComplete = $result["installation_completed"] ?? $this->installationComplete;
@ -162,13 +179,18 @@ class Settings {
$this->redisPort = $result["redis_port"] ?? $this->redisPort; $this->redisPort = $result["redis_port"] ?? $this->redisPort;
$this->redisPassword = $result["redis_password"] ?? $this->redisPassword; $this->redisPassword = $result["redis_password"] ?? $this->redisPassword;
date_default_timezone_set($this->timeZone); date_default_timezone_set($this->timeZone);
$this->contactGpgKey = $req->getContactGpgKey();
} }
return false; return false;
} }
public function addRows(Insert $query): void { public function addRows(Insert $query): void {
// ["name", "value", "private", "readonly"]
$query->addRow("site_name", json_encode($this->siteName), false, false) $query->addRow("site_name", json_encode($this->siteName), false, false)
->addRow("mail_contact", json_encode($this->contactMail), false, false)
->addRow("mail_contact_gpg_key_id", json_encode($this->contactGpgKey?->getId()), false, true)
->addRow("base_url", json_encode($this->baseUrl), false, false) ->addRow("base_url", json_encode($this->baseUrl), false, false)
->addRow("trusted_domains", json_encode($this->trustedDomains), false, false) ->addRow("trusted_domains", json_encode($this->trustedDomains), false, false)
->addRow("user_registration_enabled", json_encode($this->registrationAllowed), false, false) ->addRow("user_registration_enabled", json_encode($this->registrationAllowed), false, false)
@ -196,6 +218,14 @@ class Settings {
return $this->siteName; return $this->siteName;
} }
public function getContactMail(): string {
return $this->contactMail;
}
public function getContactGPGKey(): ?GpgKey {
return $this->contactGpgKey;
}
public function getTimeZone(): string { public function getTimeZone(): string {
return $this->timeZone; return $this->timeZone;
} }

@ -19,7 +19,6 @@ namespace Documents\Install {
use Core\Configuration\Configuration; use Core\Configuration\Configuration;
use Core\Configuration\CreateDatabase; use Core\Configuration\CreateDatabase;
use Core\Driver\SQL\Expression\Count;
use Core\Driver\SQL\SQL; use Core\Driver\SQL\SQL;
use Core\Elements\Body; use Core\Elements\Body;
use Core\Elements\Head; use Core\Elements\Head;
@ -187,7 +186,7 @@ namespace Documents\Install {
} }
$sql = $context->getSQL(); $sql = $context->getSQL();
if (!$sql || !$sql->isConnected()) { if (!$sql || !$sql->isConnected() || !$sql->tableExists(User::getHandler($sql)->getTableName())) {
return self::DATABASE_CONFIGURATION; return self::DATABASE_CONFIGURATION;
} }
@ -212,7 +211,7 @@ namespace Documents\Install {
$step = self::FINISH_INSTALLATION; $step = self::FINISH_INSTALLATION;
$req = new \Core\API\Settings\Set($context); $req = new \Core\API\Settings\Set($context);
$success = $req->execute(["settings" => ["installation_completed" => "1"]]); $success = $req->execute(["settings" => ["installation_completed" => true]]);
if (!$success) { if (!$success) {
$this->errorString = $req->getLastError(); $this->errorString = $req->getLastError();
} }
@ -439,9 +438,12 @@ namespace Documents\Install {
$context = $this->getDocument()->getContext(); $context = $this->getDocument()->getContext();
if ($this->getParameter("prev") === "true") { if ($this->getParameter("prev") === "true") {
// TODO: drop the previous database here? // TODO: drop the previous database here?
/*
$success = $context->getConfig()->delete("\\Site\\Configuration\\Database"); $success = $context->getConfig()->delete("\\Site\\Configuration\\Database");
$msg = $success ? "" : error_get_last(); $msg = $success ? "" : error_get_last();
return ["success" => $success, "msg" => $msg]; return ["success" => $success, "msg" => $msg];
*/
return ["success" => false, "msg" => "Cannot revert this installation step."];
} }
$username = $this->getParameter("username"); $username = $this->getParameter("username");
@ -755,7 +757,7 @@ namespace Documents\Install {
["title" => "Password", "name" => "password", "type" => "password", "required" => true], ["title" => "Password", "name" => "password", "type" => "password", "required" => true],
["title" => "Confirm Password", "name" => "confirmPassword", "type" => "password", "required" => true], ["title" => "Confirm Password", "name" => "confirmPassword", "type" => "password", "required" => true],
], ],
"previousButton" => true "previousButton" => false,
], ],
self::ADD_MAIL_SERVICE => [ self::ADD_MAIL_SERVICE => [
"title" => "Optional: Add Mail Service", "title" => "Optional: Add Mail Service",

@ -28,7 +28,7 @@ class Security extends Document {
$sql = $this->getContext()->getSQL(); $sql = $this->getContext()->getSQL();
$settings = $this->getSettings(); $settings = $this->getSettings();
$mailSettings = Settings::getAll($sql, "^mail_"); $gpgKey = $settings->getContactGPGKey();
if ($activeRoute->getPattern() === "/.well-known/security.txt") { if ($activeRoute->getPattern() === "/.well-known/security.txt") {
@ -39,7 +39,7 @@ class Security extends Document {
$expires = (new \DateTime())->setTime(0, 0, 0)->modify("+3 months"); $expires = (new \DateTime())->setTime(0, 0, 0)->modify("+3 months");
$baseUrl = $settings->getBaseUrl(); $baseUrl = $settings->getBaseUrl();
$gpgKey = null; // $gpgKey = null;
$lines = [ $lines = [
"# This project is based on the open-source framework hosted on https://github.com/rhergenreder/web-base", "# This project is based on the open-source framework hosted on https://github.com/rhergenreder/web-base",
@ -53,19 +53,16 @@ class Security extends Document {
"", "",
]; ];
if (isset($mailSettings["mail_contact"])) { $contactAddress = $settings->getContactMail();
$lines[] = "Contact: " . $mailSettings["mail_contact"]; if (!empty($contactAddress)) {
$lines[] = "Contact: " . $contactAddress;
}
if (isset($mailSettings["mail_contact_gpg_key_id"])) { if ($gpgKey !== null) {
$gpgKey = GpgKey::find($sql, $mailSettings["mail_contact_gpg_key_id"]);
if ($gpgKey) {
$lines[] = "Encryption: $baseUrl/.well-known/gpg-key.txt"; $lines[] = "Encryption: $baseUrl/.well-known/gpg-key.txt";
} }
}
}
$code = implode("\n", $lines); $code = implode("\n", $lines);
if ($gpgKey !== null) { if ($gpgKey !== null) {
$res = GpgKey::sign($code, $gpgKey->getFingerprint()); $res = GpgKey::sign($code, $gpgKey->getFingerprint());
if ($res["success"]) { if ($res["success"]) {
@ -75,18 +72,15 @@ class Security extends Document {
return $code; return $code;
} else if ($activeRoute->getPattern() === "/.well-known/gpg-key.txt") { } else if ($activeRoute->getPattern() === "/.well-known/gpg-key.txt") {
if (isset($mailSettings["mail_contact_gpg_key_id"])) {
$gpgKey = GpgKey::find($sql, $mailSettings["mail_contact_gpg_key_id"]);
if ($gpgKey !== null) { if ($gpgKey !== null) {
header("Content-Type: text/plain");
$res = $gpgKey->_export(true); $res = $gpgKey->_export(true);
if ($res["success"]) { if ($res["success"]) {
header("Content-Type: text/plain");
return $res["data"]; return $res["data"];
} else { } else {
http_response_code(500);
return "Error exporting public key: " . $res["msg"]; return "Error exporting public key: " . $res["msg"];
} }
}
} else { } else {
http_response_code(412); http_response_code(412);
return "No gpg key configured yet."; return "No gpg key configured yet.";

@ -6,7 +6,7 @@ use Core\Driver\SQL\Column\Column;
use Core\Driver\SQL\MySQL; use Core\Driver\SQL\MySQL;
use Core\Driver\SQL\PostgreSQL; use Core\Driver\SQL\PostgreSQL;
use Core\Driver\SQL\SQL; use Core\Driver\SQL\SQL;
use Core\External\PHPMailer\Exception; use Exception;
class DateAdd extends Expression { class DateAdd extends Expression {

@ -6,7 +6,7 @@ use Core\Driver\SQL\Column\Column;
use Core\Driver\SQL\MySQL; use Core\Driver\SQL\MySQL;
use Core\Driver\SQL\PostgreSQL; use Core\Driver\SQL\PostgreSQL;
use Core\Driver\SQL\SQL; use Core\Driver\SQL\SQL;
use Core\External\PHPMailer\Exception; use Exception;
class DateSub extends Expression { class DateSub extends Expression {

@ -375,7 +375,10 @@ class MySQL extends SQL {
list ($name, $alias) = $parts; list ($name, $alias) = $parts;
return "`$name` $alias"; return "`$name` $alias";
} else { } else {
return "`$table`"; $parts = explode(".", $table);
return implode(".", array_map(function ($n) {
return "`$n`";
}, $parts));
} }
} }
} }
@ -472,7 +475,6 @@ class MySQL extends SQL {
return $query; return $query;
} }
// FIXME: access mysql database instead of configured one
public function tableExists(string $tableName): bool { public function tableExists(string $tableName): bool {
$tableSchema = $this->connectionData->getProperty("database"); $tableSchema = $this->connectionData->getProperty("database");
$res = $this->select(new Count()) $res = $this->select(new Count())
@ -484,6 +486,27 @@ class MySQL extends SQL {
return $res && $res[0]["count"] > 0; return $res && $res[0]["count"] > 0;
} }
public function listTables(): ?array {
$tableSchema = $this->connectionData->getProperty("database");
$res = $this->select("TABLE_NAME")
->from("information_schema.TABLES")
->where(new Compare("TABLE_SCHEMA", $tableSchema, "=", true))
->where(new CondLike(new Column("TABLE_TYPE"), "BASE TABLE"))
->execute();
if ($res !== false) {
$tableNames = [];
foreach ($res as $row) {
$tableNames[] = $row["TABLE_NAME"];
}
return $tableNames;
}
return null;
}
} }
class RowIteratorMySQL extends RowIterator { class RowIteratorMySQL extends RowIterator {

@ -334,7 +334,10 @@ class PostgreSQL extends SQL {
list ($name, $alias) = $parts; list ($name, $alias) = $parts;
return "\"$name\" $alias"; return "\"$name\" $alias";
} else { } else {
return "\"$table\""; $parts = explode(".", $table);
return implode(".", array_map(function ($n) {
return "\"$n\"";
}, $parts));
} }
} }
} }
@ -463,6 +466,28 @@ class PostgreSQL extends SQL {
return $res && $res[0]["count"] > 0; return $res && $res[0]["count"] > 0;
} }
public function listTables(): ?array {
$tableSchema = $this->connectionData->getProperty("database");
$res = $this->select("tablename")
->from("pg_tables")
->where(new Compare("schemaname", $tableSchema))
->execute();
if ($res !== false) {
$tableNames = [];
foreach ($res as $row) {
$tableNames[] = $row["tablename"];
}
return $tableNames;
}
return null;
}
} }
class RowIteratorPostgreSQL extends RowIterator { class RowIteratorPostgreSQL extends RowIterator {

@ -2,7 +2,10 @@
namespace Core\Driver\SQL\Query; namespace Core\Driver\SQL\Query;
use Core\Driver\SQL\MySQL;
use Core\Driver\SQL\PostgreSQL;
use Core\Driver\SQL\SQL; use Core\Driver\SQL\SQL;
use Exception;
class CreateTrigger extends Query { class CreateTrigger extends Query {
@ -11,6 +14,9 @@ class CreateTrigger extends Query {
private string $event; private string $event;
private string $tableName; private string $tableName;
private array $parameters; private array $parameters;
private bool $ifNotExist;
private ?CreateProcedure $procedure; private ?CreateProcedure $procedure;
public function __construct(SQL $sql, string $triggerName) { public function __construct(SQL $sql, string $triggerName) {
@ -21,6 +27,7 @@ class CreateTrigger extends Query {
$this->event = ""; $this->event = "";
$this->parameters = []; $this->parameters = [];
$this->procedure = null; $this->procedure = null;
$this->ifNotExist = false;
} }
public function before(): CreateTrigger { public function before(): CreateTrigger {
@ -28,6 +35,11 @@ class CreateTrigger extends Query {
return $this; return $this;
} }
public function onlyIfNotExist(): CreateTrigger {
$this->ifNotExist = true;
return $this;
}
public function after(): CreateTrigger { public function after(): CreateTrigger {
$this->time = "AFTER"; $this->time = "AFTER";
return $this; return $this;
@ -70,7 +82,20 @@ class CreateTrigger extends Query {
$tableName = $this->sql->tableName($this->getTable()); $tableName = $this->sql->tableName($this->getTable());
$params = array(); $params = array();
$query = "CREATE TRIGGER $name $time $event ON $tableName FOR EACH ROW ";
if ($this->sql instanceof MySQL) {
$query = "CREATE TRIGGER";
if ($this->ifNotExist) {
$query .= " IF NOT EXISTS";
}
} else if ($this->sql instanceof PostgreSQL) {
$ifNotExists = $this->ifNotExist ? " OR REPLACE" : "";
$query = "CREATE$ifNotExists TRIGGER";
} else {
throw new Exception("CreateTrigger Not implemented for driver type: " . get_class($this->sql));
}
$query .= " $name $time $event ON $tableName FOR EACH ROW ";
$triggerBody = $this->sql->createTriggerBody($this, $this->parameters); $triggerBody = $this->sql->createTriggerBody($this, $this->parameters);
if ($triggerBody === null) { if ($triggerBody === null) {
return null; return null;

@ -130,6 +130,8 @@ abstract class SQL {
// Schema // Schema
public abstract function tableExists(string $tableName): bool; public abstract function tableExists(string $tableName): bool;
public abstract function listTables(): ?array;
/** /**
* @param Query $query * @param Query $query
* @param int $fetchType * @param int $fetchType

@ -96,11 +96,11 @@ class TemplateDocument extends Document {
"name" => $settings->getSiteName(), "name" => $settings->getSiteName(),
"url" => [ "url" => [
"base" => $settings->getBaseUrl(), "base" => $settings->getBaseUrl(),
"path" => $urlParts["path"], "path" => $urlParts["path"] ?? "" ,
"query" => $urlParts["query"] ?? "", "query" => $urlParts["query"] ?? "",
"fragment" => $urlParts["fragment"] ?? "" "fragment" => $urlParts["fragment"] ?? ""
], ],
"lastModified" => date(L('Y-m-d H:i:s'), @filemtime(self::getTemplatePath($name))), "lastModified" => date(L('general.date_time_format'), @filemtime(self::getTemplatePath($name))),
"registrationEnabled" => $settings->isRegistrationAllowed(), "registrationEnabled" => $settings->isRegistrationAllowed(),
"title" => $this->title, "title" => $this->title,
"captcha" => [ "captcha" => [

@ -14,7 +14,7 @@ abstract class View extends StaticView {
public function __construct(Document $document, bool $loadView = true) { public function __construct(Document $document, bool $loadView = true) {
$this->document = $document; $this->document = $document;
$this->title = "Untitled View"; $this->title = "Untitled View";
$this->langModules = array(); $this->langModules = [];
$this->loadView = $loadView; $this->loadView = $loadView;
} }

@ -91,6 +91,11 @@ return [
"add_group_member_title" => "Mitglied hinzufügen", "add_group_member_title" => "Mitglied hinzufügen",
"add_group_member_text" => "Einen Benutzer suchen um ihn der Gruppe hinzuzufügen", "add_group_member_text" => "Einen Benutzer suchen um ihn der Gruppe hinzuzufügen",
"edit_profile" => "Profil bearbeiten", "edit_profile" => "Profil bearbeiten",
"delete_user_error" => "Fehler beim Löschen des Benutzers",
"delete_user_title" => "Benutzer löschen",
"delete_user_text" => "Möchten Sie wirklich diesen Benutzer löschen? Dies kann nicht rückgängig gemacht werden!",
"error_reading_file" => "Fehler beim Lesen der Datei",
"invalid_gpg_key" => "Die ausgewählte Datei ist kein GPG-Public Key im ASCII-Format",
# GPG Key # GPG Key
"gpg_key_placeholder_text" => "GPG-Key im ASCII format reinziehen oder einfügen...", "gpg_key_placeholder_text" => "GPG-Key im ASCII format reinziehen oder einfügen...",

@ -17,6 +17,7 @@ return [
"available_groups" => "verfügbare Gruppen", "available_groups" => "verfügbare Gruppen",
"routes_defined" => "Routen definiert", "routes_defined" => "Routen definiert",
"error_count" => "Fehler in den letzten 48 Stunden", "error_count" => "Fehler in den letzten 48 Stunden",
"more_info" => "Mehr Infos",
# Dialogs # Dialogs
"fetch_stats_error" => "Fehler beim Holen der Stats", "fetch_stats_error" => "Fehler beim Holen der Stats",

@ -59,6 +59,7 @@ return [
"choose_file" => "Datei auswählen", "choose_file" => "Datei auswählen",
"download" => "Herunterladen", "download" => "Herunterladen",
"download_all" => "Alles Herunterladen", "download_all" => "Alles Herunterladen",
"upload_file" => "Datei hochladen",
"upload" => "Hochladen", "upload" => "Hochladen",
"uploading" => "Lade hoch", "uploading" => "Lade hoch",
"overwrite" => "Überschreiben", "overwrite" => "Überschreiben",

@ -24,11 +24,14 @@ return [
# general settings # general settings
"site_name" => "Seitenname", "site_name" => "Seitenname",
"mail_contact" => "Kontakt E-Mailadresse",
"base_url" => "Basis URL", "base_url" => "Basis URL",
"user_registration_enabled" => "Benutzerregistrierung erlauben", "user_registration_enabled" => "Benutzerregistrierung erlauben",
"allowed_extensions" => "Erlaubte Dateierweiterungen", "allowed_extensions" => "Erlaubte Dateierweiterungen",
"trusted_domains" => "Vertraute Ursprungs-Domains (* als Subdomain-Wildcard)", "trusted_domains" => "Vertraute Ursprungs-Domains (* als Subdomain-Wildcard)",
"time_zone" => "Zeitzone", "time_zone" => "Zeitzone",
"mail_contact_gpg_key" => "Kontakt GPG-Schlüssel",
"no_gpg_key_configured" => "Noch kein GPG-Schlüssel konfiguriert",
# mail settings # mail settings
"mail_enabled" => "E-Mail Versand aktiviert", "mail_enabled" => "E-Mail Versand aktiviert",
@ -65,4 +68,8 @@ return [
"save_settings_error" => "Fehler beim Speichern der Einstellungen", "save_settings_error" => "Fehler beim Speichern der Einstellungen",
"send_test_email_error" => "Fehler beim Senden der Test E-Mail", "send_test_email_error" => "Fehler beim Senden der Test E-Mail",
"send_test_email_success" => "Test E-Mail erfolgreich versendet, überprüfen Sie Ihren Posteingang!", "send_test_email_success" => "Test E-Mail erfolgreich versendet, überprüfen Sie Ihren Posteingang!",
"remove_gpg_key_error" => "Fehler beim Entfernen des GPG-Schlüssels",
"remove_gpg_key" => "GPG-Schlüssel entfernen",
"remove_gpg_key_text" => "Möchten Sie wirklich diesen GPG-Schlüssel entfernen?",
"import_gpg_key_error" => "Fehler beim Importieren des GPG-Schlüssels",
]; ];

@ -92,6 +92,11 @@ return [
"remove_group_member_text" => "Do you really want to remove user '%s' from this group?", "remove_group_member_text" => "Do you really want to remove user '%s' from this group?",
"add_group_member_title" => "Add member", "add_group_member_title" => "Add member",
"add_group_member_text" => "Search a user to add to the group", "add_group_member_text" => "Search a user to add to the group",
"delete_user_error" => "Error deleting User",
"delete_user_title" => "Delete User",
"delete_user_text" => "Are you really sure you want to delete this user? This cannot be undone!",
"error_reading_file" => "Error reading file",
"invalid_gpg_key" => "Selected file is a not a GPG Public Key in ASCII format",
# GPG Key # GPG Key
"gpg_key" => "GPG Key", "gpg_key" => "GPG Key",

@ -17,6 +17,7 @@ return [
"available_groups" => "available Groups", "available_groups" => "available Groups",
"routes_defined" => "Routes defined", "routes_defined" => "Routes defined",
"error_count" => "Errors in the past 48 hours", "error_count" => "Errors in the past 48 hours",
"more_info" => "More Info",
# Dialogs # Dialogs
"fetch_stats_error" => "Error fetching stats", "fetch_stats_error" => "Error fetching stats",

@ -54,10 +54,11 @@ return [
"sending" => "Sending", "sending" => "Sending",
# file # file
"choose_file" => "Choose File", "choose_file" => "Choose file",
"download" => "Download", "download" => "Download",
"download_all" => "Download All", "download_all" => "Download All",
"upload" => "Upload", "upload" => "Upload",
"upload_file" => "Upload file",
"uploading" => "Uploading", "uploading" => "Uploading",
"rename" => "Rename", "rename" => "Rename",
"move" => "Move", "move" => "Move",

@ -24,11 +24,14 @@ return [
# general settings # general settings
"site_name" => "Site Name", "site_name" => "Site Name",
"mail_contact" => "Contact mail address",
"base_url" => "Base URL", "base_url" => "Base URL",
"user_registration_enabled" => "Allow user registration", "user_registration_enabled" => "Allow user registration",
"allowed_extensions" => "Allowed file extensions", "allowed_extensions" => "Allowed file extensions",
"trusted_domains" => "Trusted origin domains (* as subdomain-wildcard)", "trusted_domains" => "Trusted origin domains (* as subdomain-wildcard)",
"time_zone" => "Time zone", "time_zone" => "Time zone",
"mail_contact_gpg_key" => "Contact GPG key",
"no_gpg_key_configured" => "No GPG key configured yet",
# mail settings # mail settings
"mail_enabled" => "Enable e-mail transport", "mail_enabled" => "Enable e-mail transport",
@ -65,4 +68,8 @@ return [
"save_settings_error" => "Error saving settings", "save_settings_error" => "Error saving settings",
"send_test_email_error" => "Error sending test email", "send_test_email_error" => "Error sending test email",
"send_test_email_success" => "Test email successfully sent. Please check your inbox!", "send_test_email_success" => "Test email successfully sent. Please check your inbox!",
"remove_gpg_key_error" => "Error removing GPG key",
"remove_gpg_key" => "Remove GPG key",
"remove_gpg_key_text" => "Do you really want to remove this gpg key?",
"import_gpg_key_error" => "Error importing GPG key",
]; ];

@ -125,11 +125,15 @@ class Context {
public function parseCookies(): void { public function parseCookies(): void {
if ($this->sql) { $settings = $this->getSettings();
if (!$settings->isInstalled()) {
// we cannot process user sessions or localization yet.
return;
}
if (isset($_COOKIE['session']) && is_string($_COOKIE['session']) && !empty($_COOKIE['session'])) { if (isset($_COOKIE['session']) && is_string($_COOKIE['session']) && !empty($_COOKIE['session'])) {
$this->loadSession($_COOKIE['session']); $this->loadSession($_COOKIE['session']);
} }
}
// set language by priority: 1. GET parameter, 2. cookie, 3. user's settings, 4. accept-language header // set language by priority: 1. GET parameter, 2. cookie, 3. user's settings, 4. accept-language header
if (isset($_GET['lang']) && is_string($_GET["lang"]) && !empty($_GET["lang"])) { if (isset($_GET['lang']) && is_string($_GET["lang"]) && !empty($_GET["lang"])) {
@ -139,7 +143,10 @@ class Context {
} else if ($this->user) { } else if ($this->user) {
$this->setLanguage($this->user->language); $this->setLanguage($this->user->language);
} else { } else {
$this->setLanguage(Language::fromHeader()); $language = Language::fromHeader();
if ($language !== null) {
$this->setLanguage($language);
}
} }
} }

@ -1,16 +0,0 @@
<?php
namespace Core\Objects\DatabaseEntity\Attribute;
#[\Attribute(\Attribute::TARGET_PROPERTY)] class Many {
private string $type;
public function __construct(string $type) {
$this->type = $type;
}
public function getValue(): string {
return $this->type;
}
}

@ -713,13 +713,13 @@ class DatabaseEntityHandler implements Persistable {
return $res; return $res;
} }
public function getCreateQueries(SQL $sql): array { public function getCreateQueries(SQL $sql, bool $canExist = false): array {
$queries = []; $queries = [];
$table = $this->getTableName(); $table = $this->getTableName();
// Create Table // Create Table
$queries[] = $this->getTableQuery($sql); $queries[] = $this->getTableQuery($sql, $canExist);
// pre defined values // pre defined values
$getPredefinedValues = $this->entityClass->getMethod("getPredefinedValues"); $getPredefinedValues = $this->entityClass->getMethod("getPredefinedValues");
@ -733,43 +733,64 @@ class DatabaseEntityHandler implements Persistable {
$entityLogConfig = $entityLogConfig->getValue(); $entityLogConfig = $entityLogConfig->getValue();
if (isset($entityLogConfig["insert"]) && $entityLogConfig["insert"] === true) { if (isset($entityLogConfig["insert"]) && $entityLogConfig["insert"] === true) {
$queries[] = $sql->createTrigger("${table}_trg_insert") $trigger = $sql->createTrigger("${table}_trg_insert")
->after()->insert($table) ->after()->insert($table)
->exec(new CreateProcedure($sql, "InsertEntityLog"), [ ->exec(new CreateProcedure($sql, "InsertEntityLog"), [
"tableName" => new CurrentTable(), "tableName" => new CurrentTable(),
"entityId" => new CurrentColumn("id"), "entityId" => new CurrentColumn("id"),
"lifetime" => $entityLogConfig["lifetime"] ?? 90, "lifetime" => $entityLogConfig["lifetime"] ?? 90,
]); ]);
if ($canExist) {
$trigger->onlyIfNotExist();
}
$queries[] = $trigger;
} }
if (isset($entityLogConfig["update"]) && $entityLogConfig["update"] === true) { if (isset($entityLogConfig["update"]) && $entityLogConfig["update"] === true) {
$queries[] = $sql->createTrigger("${table}_trg_update") $trigger = $sql->createTrigger("${table}_trg_update")
->after()->update($table) ->after()->update($table)
->exec(new CreateProcedure($sql, "UpdateEntityLog"), [ ->exec(new CreateProcedure($sql, "UpdateEntityLog"), [
"tableName" => new CurrentTable(), "tableName" => new CurrentTable(),
"entityId" => new CurrentColumn("id"), "entityId" => new CurrentColumn("id"),
]); ]);
if ($canExist) {
$trigger->onlyIfNotExist();
}
$queries[] = $trigger;
} }
if (isset($entityLogConfig["delete"]) && $entityLogConfig["delete"] === true) { if (isset($entityLogConfig["delete"]) && $entityLogConfig["delete"] === true) {
$queries[] = $sql->createTrigger("${table}_trg_delete") $trigger = $sql->createTrigger("${table}_trg_delete")
->after()->delete($table) ->after()->delete($table)
->exec(new CreateProcedure($sql, "DeleteEntityLog"), [ ->exec(new CreateProcedure($sql, "DeleteEntityLog"), [
"tableName" => new CurrentTable(), "tableName" => new CurrentTable(),
"entityId" => new CurrentColumn("id"), "entityId" => new CurrentColumn("id"),
]); ]);
if ($canExist) {
$trigger->onlyIfNotExist();
}
$queries[] = $trigger;
} }
return $queries; return $queries;
} }
public function getTableQuery(SQL $sql): CreateTable { public function getTableQuery(SQL $sql, bool $canExist = false): CreateTable {
$query = $sql->createTable($this->tableName) $query = $sql->createTable($this->tableName)
->onlyIfNotExists()
->addSerial("id") ->addSerial("id")
->primaryKey("id"); ->primaryKey("id");
if ($canExist) {
$query->onlyIfNotExists();
}
foreach ($this->columns as $column) { foreach ($this->columns as $column) {
$query->addColumn($column); $query->addColumn($column);
} }

@ -18,9 +18,9 @@ class GpgKey extends DatabaseEntity {
private \DateTime $expires; private \DateTime $expires;
#[DefaultValue(CurrentTimeStamp::class)] private \DateTime $added; #[DefaultValue(CurrentTimeStamp::class)] private \DateTime $added;
public function __construct(string $fingerprint, string $algorithm, \DateTime $expires) { public function __construct(string $fingerprint, string $algorithm, \DateTime $expires, bool $confirmed = false) {
parent::__construct(); parent::__construct();
$this->confirmed = false; $this->confirmed = $confirmed;
$this->fingerprint = $fingerprint; $this->fingerprint = $fingerprint;
$this->algorithm = $algorithm; $this->algorithm = $algorithm;
$this->expires = $expires; $this->expires = $expires;

@ -41,7 +41,10 @@ class Router {
} }
public function run(string $url, array &$pathParams): ?Route { public function run(string $url, array &$pathParams): ?Route {
$this->requestedUri = $url; // replace multiple leading slashes with one, otherwise parse_url() might interpret path as domain
// e.g. //index.php --> /index.php
$this->requestedUri = preg_replace("/^\/{2,}/", "/", $url);
$url = strtok($url, "?"); $url = strtok($url, "?");
foreach ($this->routes as $route) { foreach ($this->routes as $route) {
$match = $route->match($url); $match = $route->match($url);

@ -10,7 +10,7 @@ if (is_file($autoLoad)) {
require_once $autoLoad; require_once $autoLoad;
} }
const WEBBASE_VERSION = "2.4.0"; const WEBBASE_VERSION = "2.4.4";
spl_autoload_extensions(".php"); spl_autoload_extensions(".php");
spl_autoload_register(function ($class) { spl_autoload_register(function ($class) {

0
Site/Elements/.gitkeep Normal file

63
cli.php

@ -13,10 +13,10 @@ use Core\Driver\SQL\Condition\Compare;
use Core\Driver\SQL\Condition\CondIn; use Core\Driver\SQL\Condition\CondIn;
use Core\Driver\SQL\Expression\DateSub; use Core\Driver\SQL\Expression\DateSub;
use Core\Driver\SQL\SQL; use Core\Driver\SQL\SQL;
use Core\Objects\Context;
use Core\Objects\ConnectionData; use Core\Objects\ConnectionData;
// TODO: is this available in all installations? // TODO: is this available in all installations?
use Core\Objects\Context;
use JetBrains\PhpStorm\NoReturn; use JetBrains\PhpStorm\NoReturn;
function printLine(string $line = ""): void { function printLine(string $line = ""): void {
@ -40,7 +40,7 @@ function getDatabaseConfig(): ConnectionData {
return new $configClass(); return new $configClass();
} }
$context = \Core\Objects\Context::instance(); $context = Context::instance();
if (!$context->isCLI()) { if (!$context->isCLI()) {
_exit("Can only be executed via CLI"); _exit("Can only be executed via CLI");
} }
@ -96,8 +96,57 @@ function handleDatabase(array $argv): void {
$action = $argv[2] ?? ""; $action = $argv[2] ?? "";
if ($action === "migrate") { if ($action === "migrate") {
$fileName = $argv[3] ?? "";
if (empty($fileName)) {
_exit("Usage: cli.php db migrate <file>");
}
$filePath = realpath($fileName);
if (!$filePath) {
_exit("File not found: $fileName");
}
$corePatches = implode(DIRECTORY_SEPARATOR, [WEBROOT, "Core", "Configuration", "Patch", ""]);
$sitePatches = implode(DIRECTORY_SEPARATOR, [WEBROOT, "Site", "Configuration", "Patch", ""]);
if (!endsWith($filePath, ".php") || (!startsWith($filePath, $corePatches) && !startsWith($filePath, $sitePatches))) {
_exit("invalid patch file: $filePath. Must be located in either Core or Site patch folder and have '.php' as extension");
}
$sql = connectSQL() or die(); $sql = connectSQL() or die();
_exit("Not implemented: migrate"); $queries = [];
@include_once $filePath;
if (empty($queries)) {
_exit("No queries loaded.");
}
$success = true;
$queryCount = count($queries);
$logger = new \Core\Driver\Logger\Logger("CLI", $sql);
$logger->info("Migrating DB with: " . $fileName);
printLine("Executing $queryCount queries");
$sql->startTransaction();
$queryIndex = 1;
foreach ($queries as $query) {
if ($query->execute() === false) {
$success = false;
printLine("Error executing query: " . $sql->getLastError());
$logger->error("Error while migrating db: " . $sql->getLastError());
$sql->rollback();
break;
} else {
printLine("$queryIndex/$queryCount: success!");
$queryIndex++;
}
}
if ($success) {
$sql->commit();
}
printLine("Done.");
} else if (in_array($action, ["export", "import", "shell"])) { } else if (in_array($action, ["export", "import", "shell"])) {
// database config // database config
@ -302,7 +351,7 @@ function onMaintenance(array $argv): void {
} }
printLine("$ git log HEAD..$pullBranch --oneline"); printLine("$ git log HEAD..$pullBranch --oneline");
exec("git log HEAD..$pullBranch --oneline", $gitLog, $ret); exec("git log HEAD..$pullBranch --oneline 2>&1", $gitLog, $ret);
if ($ret !== 0) { if ($ret !== 0) {
$logger->warning("Update stopped. git log returned:\n" . implode("\n", $gitLog)); $logger->warning("Update stopped. git log returned:\n" . implode("\n", $gitLog));
die(); die();
@ -312,7 +361,7 @@ function onMaintenance(array $argv): void {
printLine("Found updates, checking repository state"); printLine("Found updates, checking repository state");
printLine("$ git diff-index --quiet HEAD --"); // check for any uncommitted changes printLine("$ git diff-index --quiet HEAD --"); // check for any uncommitted changes
exec("git diff-index --quiet HEAD --", $gitDiff, $ret); exec("git diff-index --quiet HEAD -- 2>&1", $gitDiff, $ret);
if ($ret !== 0) { if ($ret !== 0) {
$logger->warning("Update stopped due to uncommitted changes"); $logger->warning("Update stopped due to uncommitted changes");
_exit("You have uncommitted changes. Please commit them before updating."); _exit("You have uncommitted changes. Please commit them before updating.");
@ -328,7 +377,7 @@ function onMaintenance(array $argv): void {
printLine("Ready to update, pulling and merging"); printLine("Ready to update, pulling and merging");
printLine("$ git pull " . str_replace("/", " ", $pullBranch) . " --no-ff"); printLine("$ git pull " . str_replace("/", " ", $pullBranch) . " --no-ff");
exec("git pull " . str_replace("/", " ", $pullBranch) . " --no-ff", $gitPull, $ret); exec("git pull " . str_replace("/", " ", $pullBranch) . " --no-ff 2>&1", $gitPull, $ret);
if ($ret !== 0) { if ($ret !== 0) {
printLine(); printLine();
printLine("Update could not be applied, check the git output."); printLine("Update could not be applied, check the git output.");
@ -959,7 +1008,7 @@ class $apiName extends Request {
$argv = $_SERVER['argv']; $argv = $_SERVER['argv'];
$registeredCommands = [ $registeredCommands = [
"help" => ["handler" => "printHelp", "description" => "prints this help page"], "help" => ["handler" => "printHelp", "description" => "prints this help page"],
"db" => ["handler" => "handleDatabase", "description" => "database actions like importing, exporting and shell"], "db" => ["handler" => "handleDatabase", "description" => "database actions like importing, exporting and shell", "requiresDocker" => ["migrate"]],
"routes" => ["handler" => "onRoutes", "description" => "view and modify routes", "requiresDocker" => true], "routes" => ["handler" => "onRoutes", "description" => "view and modify routes", "requiresDocker" => true],
"maintenance" => ["handler" => "onMaintenance", "description" => "toggle maintenance mode", "requiresDocker" => true], "maintenance" => ["handler" => "onMaintenance", "description" => "toggle maintenance mode", "requiresDocker" => true],
"test" => ["handler" => "onTest", "description" => "run unit and integration tests", "requiresDocker" => true], "test" => ["handler" => "onTest", "description" => "run unit and integration tests", "requiresDocker" => true],

@ -30,7 +30,10 @@ $context->parseCookies();
$currentHostName = getCurrentHostName(); $currentHostName = getCurrentHostName();
$installation = !$sql || ($sql->isConnected() && !$settings->isInstalled()); $installation = !$sql || ($sql->isConnected() && !$settings->isInstalled());
$requestedUri = $_GET["site"] ?? $_GET["api"] ?? $_SERVER["REQUEST_URI"]; $requestedUri = $_GET["site"] ?? $_GET["api"] ?? null;
if (!is_string($requestedUri)) {
$requestedUri = $_SERVER["REQUEST_URI"];
}
if ($installation) { if ($installation) {
$requestedUri = Router::cleanURL($requestedUri); $requestedUri = Router::cleanURL($requestedUri);

@ -88,7 +88,7 @@ export default function AdminDashboard(props) {
<Route path={"/admin/logs"} element={<LogView {...controlObj} />}/> <Route path={"/admin/logs"} element={<LogView {...controlObj} />}/>
<Route path={"/admin/permissions"} element={<AccessControlList {...controlObj} />}/> <Route path={"/admin/permissions"} element={<AccessControlList {...controlObj} />}/>
<Route path={"/admin/routes"} element={<RouteListView {...controlObj} />}/> <Route path={"/admin/routes"} element={<RouteListView {...controlObj} />}/>
<Route path={"/admin/routes/:routeId"} element={<RouteEditView {...controlObj} />}/> <Route path={"/admin/route/:routeId"} element={<RouteEditView {...controlObj} />}/>
<Route path={"/admin/settings"} element={<SettingsView {...controlObj} />}/> <Route path={"/admin/settings"} element={<SettingsView {...controlObj} />}/>
<Route path={"/admin/profile"} element={<ProfileView {...controlObj} />}/> <Route path={"/admin/profile"} element={<ProfileView {...controlObj} />}/>
<Route path={"*"} element={<View404 />} /> <Route path={"*"} element={<View404 />} />

@ -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;

@ -15,6 +15,6 @@ export default function ProfileLink(props) {
return <Box display={"grid"} sx={newSx} gridTemplateColumns={size + "px auto"} alignItems={"center"} {...other}> return <Box display={"grid"} sx={newSx} gridTemplateColumns={size + "px auto"} alignItems={"center"} {...other}>
<ProfilePicture user={user} size={size} /> <ProfilePicture user={user} size={size} />
{text ? text : (user.fullName || user.name)} {typeof text === "string" ? text : (user.fullName || user.name)}
</Box> </Box>
} }

@ -23,8 +23,7 @@ const DrawerHeader = styled('div')(({ theme }) => ({
padding: theme.spacing(0, 1), padding: theme.spacing(0, 1),
...theme.mixins.toolbar, ...theme.mixins.toolbar,
"& > button": { "& > button": {
display: 'flex', display: "flex",
marginLeft: "auto",
}, },
"& > img": { "& > img": {
width: 30, width: 30,
@ -122,22 +121,25 @@ export default function Sidebar(props) {
onFetchLanguages(); onFetchLanguages();
}, []); }, []);
const menuItems = { const menuItems= {
"dashboard": { "dashboard": {
"name": "admin.dashboard", "name": "admin.dashboard",
"icon": <QueryStats /> "icon": <QueryStats />
}, },
"users": { "users": {
"name": "admin.users", "name": "admin.users",
"icon": <People /> "icon": <People />,
"match": /\/admin\/(users|user\/.*)/
}, },
"groups": { "groups": {
"name": "admin.groups", "name": "admin.groups",
"icon": <Groups /> "icon": <Groups />,
"match": /\/admin\/(groups|group\/.*)/
}, },
"routes": { "routes": {
"name": "admin.page_routes", "name": "admin.page_routes",
"icon": <Route /> "icon": <Route />,
"match": /\/admin\/(routes|route\/.*)/
}, },
"settings": { "settings": {
"name": "admin.settings", "name": "admin.settings",
@ -172,8 +174,15 @@ export default function Sidebar(props) {
let li = []; let li = [];
for (const [id, menuItem] of Object.entries(menuItems)) { for (const [id, menuItem] of Object.entries(menuItems)) {
let active;
if (menuItem.hasOwnProperty("match")) {
active = !!menuItem.match.exec(currentPath);
} else {
const match= /^\/admin\/(.*)$/.exec(currentPath); const match= /^\/admin\/(.*)$/.exec(currentPath);
const active = match?.length >= 2 && match[1] === id; active = match?.length >= 2 && match[1] === id;
}
li.push(<NavbarItem key={id} {...menuItem} active={active} onClick={() => navigate(`/admin/${id}`)} />); li.push(<NavbarItem key={id} {...menuItem} active={active} onClick={() => navigate(`/admin/${id}`)} />);
} }
@ -188,14 +197,16 @@ export default function Sidebar(props) {
<img src={"/img/icons/logo.png"} alt={"Logo"} /> <img src={"/img/icons/logo.png"} alt={"Logo"} />
<span>WebBase</span> <span>WebBase</span>
</>} </>}
<IconButton onClick={() => setDrawerOpen(!drawerOpen)}> <IconButton sx={{marginLeft: drawerOpen ? "auto" : 0}} onClick={() => setDrawerOpen(!drawerOpen)}>
{drawerOpen ? <ChevronLeftIcon/> : <ChevronRightIcon/>} {drawerOpen ? <ChevronLeftIcon/> : <ChevronRightIcon/>}
</IconButton> </IconButton>
</DrawerHeader> </DrawerHeader>
<Divider/> <Divider/>
<ListItem sx={{display: 'block'}}> <ListItem sx={{display: 'block'}}>
<Box sx={{opacity: drawerOpen ? 1 : 0}}>{L("account.logged_in_as")}:</Box> <Box sx={{opacity: drawerOpen ? 1 : 0}}>{L("account.logged_in_as")}:</Box>
<ProfileLink user={api.user} size={30} sx={{marginTop: 1, gridGap: 16, fontWeight: "bold" }} <ProfileLink text={drawerOpen ? null : ""}
user={api.user} size={30}
sx={{marginTop: 1, gridGap: 16, fontWeight: "bold" }}
onClick={() => navigate("/admin/profile")} /> onClick={() => navigate("/admin/profile")} />
</ListItem> </ListItem>
<Divider/> <Divider/>
@ -216,7 +227,6 @@ export default function Sidebar(props) {
: <ListItemButton sx={{ : <ListItemButton sx={{
minHeight: 48, minHeight: 48,
justifyContent: 'center', justifyContent: 'center',
px: 2.5,
}}> }}>
<Dropdown> <Dropdown>
<ListItemIcon onClick={e => setAnchorEl(e.currentTarget)} sx={{ <ListItemIcon onClick={e => setAnchorEl(e.currentTarget)} sx={{

@ -3,7 +3,7 @@ import {LocaleContext} from "shared/locale";
import {Link} from "react-router-dom"; import {Link} from "react-router-dom";
import usePagination from "shared/hooks/pagination"; import usePagination from "shared/hooks/pagination";
import {DataColumn, DataTable, DateTimeColumn, NumericColumn, StringColumn} from "shared/elements/data-table"; import {DataColumn, DataTable, DateTimeColumn, NumericColumn, StringColumn} from "shared/elements/data-table";
import {Box, FormControl, FormGroup, FormLabel, Grid, IconButton, MenuItem, TextField} from "@mui/material"; import {Box, FormControl, FormGroup, FormLabel, Grid, IconButton, MenuItem, styled, TextField} from "@mui/material";
import {DateTimePicker} from "@mui/x-date-pickers"; import {DateTimePicker} from "@mui/x-date-pickers";
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns'; import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
@ -12,12 +12,19 @@ import {format, toDate} from "date-fns";
import {ExpandLess, ExpandMore} from "@mui/icons-material"; import {ExpandLess, ExpandMore} from "@mui/icons-material";
import ViewContent from "../elements/view-content"; import ViewContent from "../elements/view-content";
const StyledLogMessage = styled(Box)(props => ({
alignSelf: "center",
"& pre": {
whiteSpace: "break-spaces"
}
}));
export default function LogView(props) { export default function LogView(props) {
// meta // meta
const api = props.api; const api = props.api;
const showDialog = props.showDialog; const showDialog = props.showDialog;
const {translate: L, requestModules, currentLocale} = useContext(LocaleContext); const {translate: L, requestModules, currentLocale, toDateFns} = useContext(LocaleContext);
const pagination = usePagination(); const pagination = usePagination();
// data // data
@ -89,11 +96,11 @@ export default function LogView(props) {
</IconButton> </IconButton>
} }
</Box> </Box>
<Box alignSelf={"center"}> <StyledLogMessage>
<pre> <pre>
{entry.showDetails ? entry.message : lines[0]} {entry.showDetails ? entry.message : lines[0]}
</pre> </pre>
</Box> </StyledLogMessage>
</Box> </Box>
} }
return column; return column;
@ -130,7 +137,7 @@ export default function LogView(props) {
<FormGroup> <FormGroup>
<FormLabel>{L("logs.timestamp")}</FormLabel> <FormLabel>{L("logs.timestamp")}</FormLabel>
<FormControl> <FormControl>
<LocalizationProvider dateAdapter={AdapterDateFns}> <LocalizationProvider dateAdapter={AdapterDateFns} adapterLocale={toDateFns()}>
<DateTimePicker label={L("logs.timestamp_placeholder") + "…"} <DateTimePicker label={L("logs.timestamp_placeholder") + "…"}
value={timestamp ? toDate(new Date()) : null} value={timestamp ? toDate(new Date()) : null}
format={L("general.datefns_datetime_format_precise")} format={L("general.datefns_datetime_format_precise")}

@ -39,6 +39,12 @@ const StyledStatBox = styled(Alert)((props) => ({
}, },
"& div:nth-of-type(1)": { "& div:nth-of-type(1)": {
padding: props.theme.spacing(2), padding: props.theme.spacing(2),
"& span": {
fontSize: "2.5em",
},
"& p": {
fontSize: "1em",
}
}, },
"& div:nth-of-type(2) > svg": { "& div:nth-of-type(2) > svg": {
position: "absolute", position: "absolute",
@ -49,8 +55,18 @@ const StyledStatBox = styled(Alert)((props) => ({
}, },
"& div:nth-of-type(3)": { "& div:nth-of-type(3)": {
backdropFilter: "brightness(70%)", backdropFilter: "brightness(70%)",
textAlign: "right",
padding: props.theme.spacing(0.5), padding: props.theme.spacing(0.5),
"& a": {
display: "grid",
gridTemplateColumns: "auto 30px",
alignItems: "center",
justifyContent: "end",
textDecoration: "none",
"& svg": {
textAlign: "center",
justifySelf: "center"
}
}
} }
}, },
})); }));
@ -60,7 +76,7 @@ const StatBox = (props) => <StyledStatBox variant={"filled"} icon={false}
<Box> <Box>
{!isNaN(props.count) ? {!isNaN(props.count) ?
<> <>
<h2>{props.count}</h2> <span>{props.count}</span>
<p>{props.text}</p> <p>{props.text}</p>
</> : <CircularProgress variant={"determinate"} /> </> : <CircularProgress variant={"determinate"} />
} }
@ -68,7 +84,8 @@ const StatBox = (props) => <StyledStatBox variant={"filled"} icon={false}
<Box>{props.icon}</Box> <Box>{props.icon}</Box>
<Box> <Box>
<Link to={props.link}> <Link to={props.link}>
More info <ArrowCircleRight /> <span>{props.L("admin.more_info")}</span>
<ArrowCircleRight />
</Link> </Link>
</Box> </Box>
</StyledStatBox> </StyledStatBox>
@ -131,25 +148,25 @@ export default function Overview(props) {
<StatBox color={"info"} count={stats?.userCount} <StatBox color={"info"} count={stats?.userCount}
text={L("admin.users_registered")} text={L("admin.users_registered")}
icon={<People/>} icon={<People/>}
link={"/admin/users"}/> link={"/admin/users"} L={L}/>
</Grid> </Grid>
<Grid item xs={6} lg={3}> <Grid item xs={6} lg={3}>
<StatBox color={"success"} count={stats?.groupCount} <StatBox color={"success"} count={stats?.groupCount}
text={L("admin.available_groups")} text={L("admin.available_groups")}
icon={<Groups/>} icon={<Groups/>}
link={"/admin/groups"}/> link={"/admin/groups"} L={L}/>
</Grid> </Grid>
<Grid item xs={6} lg={3}> <Grid item xs={6} lg={3}>
<StatBox color={"warning"} count={stats?.pageCount} <StatBox color={"warning"} count={stats?.pageCount}
text={L("admin.routes_defined")} text={L("admin.routes_defined")}
icon={<LibraryBooks/>} icon={<LibraryBooks/>}
link={"/admin/routes"}/> link={"/admin/routes"} L={L}/>
</Grid> </Grid>
<Grid item xs={6} lg={3}> <Grid item xs={6} lg={3}>
<StatBox color={"error"} count={stats?.errorCount} <StatBox color={"error"} count={stats?.errorCount}
text={L("admin.error_count")} text={L("admin.error_count")}
icon={<BugReport />} icon={<BugReport />}
link={"/admin/logs"}/> link={"/admin/logs"} L={L}/>
</Grid> </Grid>
</Grid> </Grid>
<Box m={2} p={2} component={Paper}> <Box m={2} p={2} component={Paper}>

@ -5,6 +5,7 @@ import {CheckCircle, CloudUpload, ErrorOutline, Remove, Upload, VpnKey} from "@m
import SpacedFormGroup from "../../elements/form-group"; import SpacedFormGroup from "../../elements/form-group";
import ButtonBar from "../../elements/button-bar"; import ButtonBar from "../../elements/button-bar";
import CollapseBox from "./collapse-box"; import CollapseBox from "./collapse-box";
import VisuallyHiddenInput from "../../elements/hidden-file-upload";
const GpgKeyField = styled(TextField)((props) => ({ const GpgKeyField = styled(TextField)((props) => ({
"& > div": { "& > div": {
@ -24,18 +25,6 @@ const GpgFingerprintBox = styled(Box)((props) => ({
} }
})); }));
const VisuallyHiddenInput = styled('input')({
clip: 'rect(0 0 0 0)',
clipPath: 'inset(50%)',
height: 1,
overflow: 'hidden',
position: 'absolute',
bottom: 0,
left: 0,
whiteSpace: 'nowrap',
width: 1,
});
export default function GpgBox(props) { export default function GpgBox(props) {
// meta // meta
@ -87,7 +76,7 @@ export default function GpgBox(props) {
data += event.target.result; data += event.target.result;
if (reader.readyState === 2) { if (reader.readyState === 2) {
if (!data.match(/^-+\s*BEGIN/m)) { if (!data.match(/^-+\s*BEGIN/m)) {
showDialog(L("Selected file is a not a GPG Public Key in ASCII format"), L("Error reading file")); showDialog(L("account.invalid_gpg_key"), L("account.error_reading_file"));
return false; return false;
} else { } else {
callback(data); callback(data);
@ -98,9 +87,7 @@ export default function GpgBox(props) {
reader.readAsText(file); reader.readAsText(file);
}, [showDialog]); }, [showDialog]);
return <CollapseBox title={L("account.gpg_key")} {...other} return <CollapseBox title={L("account.gpg_key")} icon={<VpnKey />} {...other}>
icon={<VpnKey />}>
{ {
profile.gpgKey ? <Box> profile.gpgKey ? <Box>
<GpgFingerprintBox mb={2}> <GpgFingerprintBox mb={2}>
@ -150,7 +137,7 @@ export default function GpgBox(props) {
variant="outlined" variant="outlined"
startIcon={<CloudUpload />} startIcon={<CloudUpload />}
component={"label"}> component={"label"}>
Upload file {L("general.upload_file")}
<VisuallyHiddenInput type={"file"} onChange={e => { <VisuallyHiddenInput type={"file"} onChange={e => {
let file = e.target.files[0]; let file = e.target.files[0];
getFileContents(file, (data) => { getFileContents(file, (data) => {

@ -8,9 +8,8 @@ import MfaFido from "./mfa-fido";
import CollapseBox from "./collapse-box"; import CollapseBox from "./collapse-box";
const MfaStatusBox = styled(Box)((props) => ({ const MfaStatusBox = styled(Box)((props) => ({
"& > svg": { display: "grid",
marginRight: props.theme.spacing(1), gridTemplateColumns: "30px auto",
},
})); }));
const MFAOptions = styled(Box)((props) => ({ const MFAOptions = styled(Box)((props) => ({
@ -69,7 +68,7 @@ export default function MultiFactorBox(props) {
<Box> <Box>
<MfaStatusBox mb={2}> <MfaStatusBox mb={2}>
<CheckCircle color="info" title={L("account.two_factor_confirmed")} /> <CheckCircle color="info" title={L("account.two_factor_confirmed")} />
{L("account.2fa_type_" + profile.twoFactorToken.type)} <span>{L("account.2fa_type_" + profile.twoFactorToken.type)}</span>
</MfaStatusBox> </MfaStatusBox>
<SpacedFormGroup> <SpacedFormGroup>
<FormLabel>{L("account.password")}</FormLabel> <FormLabel>{L("account.password")}</FormLabel>
@ -82,7 +81,7 @@ export default function MultiFactorBox(props) {
</FormControl> </FormControl>
</SpacedFormGroup> </SpacedFormGroup>
<Button startIcon={is2FARemoving ? <CircularProgress size={12} /> : <Remove />} <Button startIcon={is2FARemoving ? <CircularProgress size={12} /> : <Remove />}
color="danger" onClick={onRemove2FA} color="error" onClick={onRemove2FA}
variant="outlined" size="small" variant="outlined" size="small"
disabled={is2FARemoving || !api.hasPermission("tfa/remove")}> disabled={is2FARemoving || !api.hasPermission("tfa/remove")}>
{is2FARemoving ? L("general.removing") + "…" : L("general.remove")} {is2FARemoving ? L("general.removing") + "…" : L("general.remove")}

@ -88,7 +88,7 @@ export default function RouteEditView(props) {
api.addRoute(...args).then(res => { api.addRoute(...args).then(res => {
setSaving(false); setSaving(false);
if (res.success) { if (res.success) {
navigate("/admin/routes/" + res.routeId); navigate("/admin/route/" + res.routeId);
} else { } else {
showDialog(res.msg, L("routes.save_route_error")); showDialog(res.msg, L("routes.save_route_error"));
} }

@ -131,7 +131,7 @@ export default function RouteListView(props) {
</Button> </Button>
<Button variant={"outlined"} color={"success"} startIcon={<Add />} size={"small"} <Button variant={"outlined"} color={"success"} startIcon={<Add />} size={"small"}
disabled={!props.api.hasPermission("routes/add")} disabled={!props.api.hasPermission("routes/add")}
onClick={() => navigate("/admin/routes/new")} > onClick={() => navigate("/admin/route/new")} >
{L("general.add")} {L("general.add")}
</Button> </Button>
<Button variant={"outlined"} color={"info"} startIcon={<Cached />} size={"small"} <Button variant={"outlined"} color={"info"} startIcon={<Cached />} size={"small"}
@ -173,7 +173,7 @@ export default function RouteListView(props) {
<IconButton size={"small"} title={L("general.edit")} <IconButton size={"small"} title={L("general.edit")}
disabled={!api.hasPermission("routes/add")} disabled={!api.hasPermission("routes/add")}
color={"primary"} color={"primary"}
onClick={() => navigate("/admin/routes/" + id)}> onClick={() => navigate("/admin/route/" + id)}>
<Edit /> <Edit />
</IconButton> </IconButton>
<IconButton size={"small"} title={L("general.delete")} <IconButton size={"small"} title={L("general.delete")}

@ -0,0 +1,99 @@
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}
disabled={!api.hasPermission(isConfigured ? "settings/removeGPG" : "settings/importGPG")}>
{ isConfigured ? <Delete color={"error"} /> : <Upload color={"success"} /> }
</IconButton>
<VisuallyHiddenInput ref={fileInputRef} type={"file"} onChange={e => {
let file = e.target.files[0];
getFileContents(file, (data) => {
onImportGPG(data);
});
return false;
}} />
<TextField variant={"outlined"} size={"small"} disabled={true}
value={value?.fingerprint ?? L("settings.no_gpg_key_configured")} />
</StyledGpgKeyInput>
}

@ -3,7 +3,7 @@ import {LocaleContext} from "shared/locale";
import { import {
Box, Button, Box, Button,
CircularProgress, FormControl, CircularProgress, FormControl,
FormGroup, FormLabel, Grid, IconButton, FormLabel, Grid, IconButton,
Paper, Paper,
Tab, Tab,
Table, Table,
@ -23,7 +23,7 @@ import {
RestartAlt, RestartAlt,
Save, Save,
Send, Send,
SettingsApplications, SmartToy, Storage SettingsApplications, SmartToy, Storage,
} from "@mui/icons-material"; } from "@mui/icons-material";
import TIME_ZONES from "shared/time-zones"; import TIME_ZONES from "shared/time-zones";
import ButtonBar from "../../elements/button-bar"; import ButtonBar from "../../elements/button-bar";
@ -34,10 +34,12 @@ import SettingsPasswordInput from "./input-password";
import SettingsTextInput from "./input-text"; import SettingsTextInput from "./input-text";
import SettingsSelection from "./input-selection"; import SettingsSelection from "./input-selection";
import ViewContent from "../../elements/view-content"; import ViewContent from "../../elements/view-content";
import GpgKeyInput from "./input-gpg-key";
import SpacedFormGroup from "../../elements/form-group";
export default function SettingsView(props) { export default function SettingsView(props) {
// TODO: website-logo (?), mail_contact, mail_contact_gpg_key_id // TODO: website-logo (?), mail_contact_gpg_key_id
// meta // meta
const api = props.api; const api = props.api;
@ -47,6 +49,7 @@ export default function SettingsView(props) {
"general": [ "general": [
"base_url", "base_url",
"site_name", "site_name",
"mail_contact",
"user_registration_enabled", "user_registration_enabled",
"time_zone", "time_zone",
"allowed_extensions", "allowed_extensions",
@ -75,6 +78,8 @@ export default function SettingsView(props) {
] ]
}; };
const CUSTOM_KEYS = ["mail_contact_gpg_key"];
// data // data
const [fetchSettings, setFetchSettings] = useState(true); const [fetchSettings, setFetchSettings] = useState(true);
const [settings, setSettings] = useState(null); const [settings, setSettings] = useState(null);
@ -94,8 +99,12 @@ export default function SettingsView(props) {
}, [])).includes(key); }, [])).includes(key);
} }
const isCustom = (key) => {
return CUSTOM_KEYS.includes(key);
}
useEffect(() => { useEffect(() => {
requestModules(props.api, ["general", "settings"], currentLocale).then(data => { requestModules(props.api, ["general", "settings", "account"], currentLocale).then(data => {
if (!data.success) { if (!data.success) {
showDialog("Error fetching translations: " + data.msg); showDialog("Error fetching translations: " + data.msg);
} }
@ -115,7 +124,9 @@ export default function SettingsView(props) {
return obj; return obj;
}, {}) }, {})
); );
setUncategorizedKeys(Object.keys(data.settings).filter(key => isUncategorized(key))); setUncategorizedKeys(Object.keys(data.settings)
.filter(key => !isCustom(key))
.filter(key => isUncategorized(key)));
} }
}); });
} }
@ -132,7 +143,15 @@ export default function SettingsView(props) {
const onSaveSettings = useCallback(() => { const onSaveSettings = useCallback(() => {
setSaving(true); setSaving(true);
api.saveSettings(settings).then(data => {
let settingsToSave = {...settings};
for (const key of CUSTOM_KEYS) {
if (settingsToSave.hasOwnProperty(key)) {
delete settingsToSave[key];
}
}
api.saveSettings(settingsToSave).then(data => {
setSaving(false); setSaving(false);
if (data.success) { if (data.success) {
showDialog(L("settings.save_settings_success"), L("general.success")); showDialog(L("settings.save_settings_success"), L("general.success"));
@ -253,6 +272,13 @@ export default function SettingsView(props) {
if (selectedTab === "general") { if (selectedTab === "general") {
return [ return [
renderTextInput("site_name"), renderTextInput("site_name"),
renderTextInput("mail_contact", false, {type: "email"}),
<SpacedFormGroup key={"mail-contact-gpg-key"}>
<FormLabel>{L("settings.mail_contact_gpg_key")}</FormLabel>
<GpgKeyInput value={settings.mail_contact_gpg_key} api={api}
showDialog={showDialog}
onChange={value => setSettings({...settings, mail_contact_gpg_key: value})}/>
</SpacedFormGroup>,
renderTextInput("base_url"), renderTextInput("base_url"),
renderTextValuesInput("trusted_domains"), renderTextValuesInput("trusted_domains"),
renderCheckBox("user_registration_enabled"), renderCheckBox("user_registration_enabled"),
@ -269,7 +295,7 @@ export default function SettingsView(props) {
renderPasswordInput("mail_password", !settings.mail_enabled), renderPasswordInput("mail_password", !settings.mail_enabled),
renderTextInput("mail_footer", !settings.mail_enabled), renderTextInput("mail_footer", !settings.mail_enabled),
renderCheckBox("mail_async", !settings.mail_enabled), renderCheckBox("mail_async", !settings.mail_enabled),
<FormGroup key={"mail-test"}> <SpacedFormGroup key={"mail-test"}>
<FormLabel>{L("settings.send_test_email")}</FormLabel> <FormLabel>{L("settings.send_test_email")}</FormLabel>
<FormControl disabled={!settings.mail_enabled}> <FormControl disabled={!settings.mail_enabled}>
<Grid container spacing={1}> <Grid container spacing={1}>
@ -292,7 +318,7 @@ export default function SettingsView(props) {
</Grid> </Grid>
</Grid> </Grid>
</FormControl> </FormControl>
</FormGroup> </SpacedFormGroup>
]; ];
} else if (selectedTab === "captcha") { } else if (selectedTab === "captcha") {
let captchaOptions = {}; let captchaOptions = {};

@ -16,7 +16,7 @@ import * as React from "react";
import ViewContent from "../../elements/view-content"; import ViewContent from "../../elements/view-content";
import FormGroup from "../../elements/form-group"; import FormGroup from "../../elements/form-group";
import ButtonBar from "../../elements/button-bar"; import ButtonBar from "../../elements/button-bar";
import {RestartAlt, Save, Send} from "@mui/icons-material"; import {Delete, RestartAlt, Save, Send} from "@mui/icons-material";
import PasswordStrength from "shared/elements/password-strength"; import PasswordStrength from "shared/elements/password-strength";
const initialUser = { const initialUser = {
@ -51,9 +51,9 @@ export default function UserEditView(props) {
const [sendInvite, setSetInvite] = useState(isNewUser); const [sendInvite, setSetInvite] = useState(isNewUser);
useEffect(() => { useEffect(() => {
requestModules(props.api, ["general", "account"], currentLocale).then(data => { requestModules(api, ["general", "account"], currentLocale).then(data => {
if (!data.success) { if (!data.success) {
props.showDialog("Error fetching translations: " + data.msg); showDialog("Error fetching translations: " + data.msg);
} }
}); });
}, [currentLocale]); }, [currentLocale]);
@ -143,6 +143,16 @@ export default function UserEditView(props) {
setChanged(true); setChanged(true);
}, [user]); }, [user]);
const onDeleteUser = useCallback(() => {
api.deleteUser(userId).then(res => {
if (res.success) {
navigate("/admin/users");
} else {
showDialog(res.msg, L("account.delete_user_error"));
}
});
}, [api, showDialog, userId]);
useEffect(() => { useEffect(() => {
if (!isNewUser) { if (!isNewUser) {
onFetchUser(true); onFetchUser(true);
@ -163,6 +173,20 @@ export default function UserEditView(props) {
<span key={"action"}>{isNewUser ? L("general.new") : L("general.edit")}</span> <span key={"action"}>{isNewUser ? L("general.new") : L("general.edit")}</span>
]}> ]}>
<Grid container> <Grid container>
<Grid item xs={12} mt={1} mb={1}>
<Button variant={"outlined"} color={"error"} size={"small"}
startIcon={<Delete />}
disabled={isNewUser || !api.hasPermission("user/delete") || user.id === api.user.id}
onClick={() => showDialog(
L("account.delete_user_text"),
L("account.delete_user_title"),
[L("general.cancel"), L("general.confirm")],
(buttonIndex) => buttonIndex === 1 ? onDeleteUser() : true)
}
>
{L("general.delete")}
</Button>
</Grid>
<Grid item xs={12} lg={6}> <Grid item xs={12} lg={6}>
<FormGroup> <FormGroup>
<FormLabel>{L("account.name")}</FormLabel> <FormLabel>{L("account.name")}</FormLabel>

@ -73,6 +73,7 @@ export default function UserListView(props) {
new StringColumn(L("account.email"), "email"), new StringColumn(L("account.email"), "email"),
groupColumn, groupColumn,
new DateTimeColumn(L("account.registered_at"), "registeredAt"), new DateTimeColumn(L("account.registered_at"), "registeredAt"),
new DateTimeColumn(L("account.last_online"), "lastOnline"),
new BoolColumn(L("account.active"), "active", { align: "center" }), new BoolColumn(L("account.active"), "active", { align: "center" }),
new BoolColumn(L("account.confirmed"), "confirmed", { align: "center" }), new BoolColumn(L("account.confirmed"), "confirmed", { align: "center" }),
new ControlsColumn(L("general.controls"), [ new ControlsColumn(L("general.controls"), [

@ -302,6 +302,14 @@ export default class API {
return this.apiCall("settings/set", { settings: settings }); return this.apiCall("settings/set", { settings: settings });
} }
async settingsImportGPG(publicKey) {
return this.apiCall("settings/importGPG", { publicKey: publicKey });
}
async settingsRemoveGPG() {
return this.apiCall("settings/removeGPG");
}
/** MailAPI **/ /** MailAPI **/
async sendTestMail(receiver) { async sendTestMail(receiver) {
return this.apiCall("mail/test", { receiver: receiver }); return this.apiCall("mail/test", { receiver: receiver });
@ -396,8 +404,8 @@ export default class API {
} }
/** GPG API **/ /** GPG API **/
async uploadGPG(pubkey) { async uploadGPG(publicKey) {
let res = await this.apiCall("gpgKey/import", { pubkey: pubkey }); let res = await this.apiCall("gpgKey/import", { publicKey: publicKey });
if (res.success) { if (res.success) {
this.user.gpgKey = res.gpgKey; this.user.gpgKey = res.gpgKey;
} }

@ -32,7 +32,7 @@
} }
.pagination-controls { .pagination-controls {
margin-top: 6px; margin-top: 12px;
display: grid; display: grid;
grid-template-columns: 75px auto; grid-template-columns: 75px auto;
align-items: center; align-items: center;

@ -1,5 +1,7 @@
<?php <?php
namespace {
use Core\API\Parameter\Parameter; use Core\API\Parameter\Parameter;
use Core\Driver\SQL\Query\CreateTable; use Core\Driver\SQL\Query\CreateTable;
use Core\Driver\SQL\SQL; use Core\Driver\SQL\SQL;
@ -14,6 +16,7 @@ class DatabaseEntityTest extends \PHPUnit\Framework\TestCase {
static SQL $SQL; static SQL $SQL;
static Context $CONTEXT; static Context $CONTEXT;
static DatabaseEntityHandler $HANDLER; static DatabaseEntityHandler $HANDLER;
static DatabaseEntityHandler $HANDLER_RECURSIVE;
public static function setUpBeforeClass(): void { public static function setUpBeforeClass(): void {
parent::setUpBeforeClass(); parent::setUpBeforeClass();
@ -24,7 +27,9 @@ class DatabaseEntityTest extends \PHPUnit\Framework\TestCase {
self::$SQL = self::$CONTEXT->getSQL(); self::$SQL = self::$CONTEXT->getSQL();
self::$HANDLER = TestEntity::getHandler(self::$SQL); self::$HANDLER = TestEntity::getHandler(self::$SQL);
self::$HANDLER_RECURSIVE = TestEntityRecursive::getHandler(self::$SQL);
self::$HANDLER->getLogger()->unitTestMode(); self::$HANDLER->getLogger()->unitTestMode();
self::$HANDLER_RECURSIVE->getLogger()->unitTestMode();
} }
public function testCreateTable() { public function testCreateTable() {
@ -99,6 +104,65 @@ class DatabaseEntityTest extends \PHPUnit\Framework\TestCase {
public function testDropTable() { public function testDropTable() {
$this->assertTrue(self::$SQL->drop(self::$HANDLER->getTableName())->execute()); $this->assertTrue(self::$SQL->drop(self::$HANDLER->getTableName())->execute());
$this->assertTrue(self::$SQL->drop(self::$HANDLER_RECURSIVE->getTableName())->execute());
}
public function testTableNames() {
$sql = self::$SQL;
$this->assertEquals("TestEntity", TestEntity::getHandler($sql, null, true)->getTableName());
$this->assertEquals("TestEntityInherit", TestEntityInherit::getHandler($sql, null, true)->getTableName());
$this->assertEquals("TestEntityInherit", OverrideNameSpace\TestEntityInherit::getHandler($sql, null, true)->getTableName());
}
public function testCreateQueries() {
$queries = [];
$entities = [
TestEntity::class,
TestEntityInherit::class,
OverrideNameSpace\TestEntityInherit::class,
TestEntityRecursive::class,
];
\Core\Configuration\CreateDatabase::createEntityQueries(self::$SQL, $entities, $queries);
$this->assertCount(3, $queries);
$tables = [];
foreach ($queries as $query) {
$this->assertInstanceOf(CreateTable::class, $query);
$tables[] = $query->getTableName();
}
$this->assertEquals(["TestEntity", "TestEntityInherit", "TestEntityRecursive"], $tables);
}
public function testRecursive() {
$query = self::$HANDLER_RECURSIVE->getTableQuery(self::$CONTEXT->getSQL());
$this->assertInstanceOf(CreateTable::class, $query);
$this->assertTrue($query->execute());
// ID: 1
$entityA = new TestEntityRecursive();
$entityA->recursive = null;
// ID: 2
$entityB = new TestEntityRecursive();
$entityB->recursive = $entityA;
// ID: 3
$entityC = new TestEntityRecursive();
$entityC->recursive = $entityB;
$this->assertTrue($entityA->save(self::$SQL));
$this->assertTrue($entityB->save(self::$SQL));
$this->assertTrue($entityC->save(self::$SQL));
$fetchedEntity = TestEntityRecursive::find(self::$SQL, 3, true, true);
$this->assertInstanceOf(TestEntityRecursive::class, $fetchedEntity);
$this->assertEquals(3, $fetchedEntity->getId());
$this->assertEquals(2, $fetchedEntity->recursive->getId());
$this->assertEquals(1, $fetchedEntity->recursive->recursive->getId());
$this->assertNull($fetchedEntity->recursive->recursive->recursive);
} }
} }
@ -110,3 +174,19 @@ class TestEntity extends DatabaseEntity {
public \DateTime $e; public \DateTime $e;
public ?int $f; public ?int $f;
} }
class TestEntityInherit extends DatabaseEntity {
public TestEntity $rel;
}
class TestEntityRecursive extends DatabaseEntity {
public ?TestEntityRecursive $recursive;
}
}
namespace OverrideNameSpace {
class TestEntityInherit extends \TestEntityInherit {
public int $new;
}
}