diff --git a/Core/API/Request.class.php b/Core/API/Request.class.php index 8d445a1..cf84e05 100644 --- a/Core/API/Request.class.php +++ b/Core/API/Request.class.php @@ -248,7 +248,7 @@ abstract class Request { // Check for permission $req = new \Core\API\Permission\Check($this->context); - $this->success = $req->execute(array("method" => self::getEndpoint())); + $this->success = $req->execute(["method" => self::getEndpoint()]); $this->lastError = $req->getLastError(); if (!$this->success) { return false; @@ -336,11 +336,19 @@ abstract class Request { return null; } - $isNestedAPI = $reflectionClass->getParentClass()->getName() !== Request::class; + $parentClass = $reflectionClass->getParentClass(); + if ($parentClass === false) { + return null; + } + + $isNestedAPI = $parentClass->getName() !== Request::class; if (!$isNestedAPI) { # e.g. /api/stats or /api/info $methodName = $reflectionClass->getShortName(); return $prefix . lcfirst($methodName); + } else if ($parentClass->getName() === \TestRequest::class) { + $methodName = $reflectionClass->getShortName(); + return $prefix . "/e2e-test/" . lcfirst($methodName); } else { # e.g. /api/user/login $methodClass = $reflectionClass; @@ -348,6 +356,10 @@ abstract class Request { while (!endsWith($nestedClass->getName(), "API")) { $methodClass = $nestedClass; $nestedClass = $nestedClass->getParentClass(); + + if (!$nestedClass) { + return null; + } } $nestedAPI = substr(lcfirst($nestedClass->getShortName()), 0, -3); diff --git a/Core/API/UserAPI.class.php b/Core/API/UserAPI.class.php index 9ec70e8..d463979 100644 --- a/Core/API/UserAPI.class.php +++ b/Core/API/UserAPI.class.php @@ -102,7 +102,7 @@ namespace Core\API { if ($count === 1) { return $string; } else { - return "the next $count ${string}s"; + return "the next $count {$string}s"; } } @@ -222,7 +222,7 @@ namespace Core\API\User { public function __construct(Context $context, $externalCall = false) { parent::__construct($context, $externalCall, - self::getPaginationParameters(['id', 'name', 'fullName', 'email', 'groups', 'registeredAt', 'confirmed'], + self::getPaginationParameters(['id', 'name', 'fullName', 'email', 'groups', 'registeredAt', 'active', 'confirmed'], 'id', 'asc') ); } diff --git a/Core/Driver/SQL/MySQL.class.php b/Core/Driver/SQL/MySQL.class.php index 30e9a79..5036949 100644 --- a/Core/Driver/SQL/MySQL.class.php +++ b/Core/Driver/SQL/MySQL.class.php @@ -510,7 +510,7 @@ class RowIteratorMySQL extends RowIterator { return $row; } - public function rewind() { + public function rewind(): void { if ($this->useCache) { $this->rowIndex = 0; } else if ($this->rowIndex !== 0) { diff --git a/Core/Driver/SQL/RowIterator.class.php b/Core/Driver/SQL/RowIterator.class.php index 437d34c..dad365b 100644 --- a/Core/Driver/SQL/RowIterator.class.php +++ b/Core/Driver/SQL/RowIterator.class.php @@ -21,15 +21,15 @@ abstract class RowIterator implements \Iterator { protected abstract function getNumRows(): int; protected abstract function fetchRow(int $index): array; - public function current() { + public function current(): array { return $this->fetchRow($this->rowIndex); } - public function next() { + public function next(): void { $this->rowIndex++; } - public function key() { + public function key(): int { return $this->rowIndex; } diff --git a/Core/Localization/de_DE/account.php b/Core/Localization/de_DE/account.php index efe2a38..0286d18 100644 --- a/Core/Localization/de_DE/account.php +++ b/Core/Localization/de_DE/account.php @@ -68,4 +68,20 @@ return [ "color" => "Farbe", "logged_in_as" => "Eingeloggt als", "active" => "Aktiv", + "group" => "Gruppe", + + # dialogs + "fetch_group_members_error" => "Fehler beim Holen der Gruppenmitglieder", + "remove_group_member_error" => "Fehler beim Entfernen des Gruppenmitglieds", + "add_group_member_error" => "Fehler beim Hinzufügen des Gruppenmitglieds", + "create_group_error" => "Fehler beim Erstellen der Gruppe", + "update_group_error" => "Error beim Aktualisieren der Gruppe", + "delete_group_error" => "Error beim Löschen der Gruppe", + "search_users_error" => "Fehler beim Suchen des Benutzers", + "delete_group_title" => "Gruppe löschen", + "delete_group_text" => "Möchten Sie diese Gruppe wirklich löschen? Dies kann nicht rückgängig gemacht werden.", + "remove_group_member_title" => "Mitglied entfernen", + "remove_group_member_text" => "Möchten Sie wirklich den Benutzer '%s' von dieser Gruppe entfernen?", + "add_group_member_title" => "Mitglied hinzufügen", + "add_group_member_text" => "Einen Benutzer suchen um ihn der Gruppe hinzuzufügen", ]; \ No newline at end of file diff --git a/Core/Localization/de_DE/routes.php b/Core/Localization/de_DE/routes.php index 1e70975..aa127a9 100644 --- a/Core/Localization/de_DE/routes.php +++ b/Core/Localization/de_DE/routes.php @@ -38,4 +38,6 @@ return [ "remove_route_error" => "Fehler beim Entfernen der Route", "regenerate_router_cache_error" => "Fehler beim Erzeugen des Router Caches", "regenerate_router_cache_success" => "Router Cache erfolgreich erzeugt", + "delete_route_title" => "Route löschen", + "delete_route_text" => "Möchten Sie wirklich die folgende Route löschen?", ]; \ No newline at end of file diff --git a/Core/Localization/en_US/account.php b/Core/Localization/en_US/account.php index d08617d..509b18c 100644 --- a/Core/Localization/en_US/account.php +++ b/Core/Localization/en_US/account.php @@ -68,4 +68,20 @@ return [ "color" => "Color", "logged_in_as" => "Logged in as", "active" => "Active", + "group" => "Group", + + # dialogs + "fetch_group_members_error" => "Error fetching group members", + "remove_group_member_error" => "Error removing group member", + "add_group_member_error" => "Error adding member", + "create_group_error" => "Error creating group", + "update_group_error" => "Error updating group", + "delete_group_error" => "Error deleting group", + "search_users_error" => "Error searching users", + "delete_group_title" => "Delete Group", + "delete_group_text" => "Do you really want to delete this group? This action cannot be undone.", + "remove_group_member_title" => "Remove member", + "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", ]; \ No newline at end of file diff --git a/Core/Localization/en_US/routes.php b/Core/Localization/en_US/routes.php index 7622dd1..3fbf838 100644 --- a/Core/Localization/en_US/routes.php +++ b/Core/Localization/en_US/routes.php @@ -38,4 +38,6 @@ return [ "remove_route_error" => "Error removing route", "regenerate_router_cache_error" => "Error regenerating router cache", "regenerate_router_cache_success" => "Router cache successfully regenerated", + "delete_route_title" => "Delete Route", + "delete_route_text" => "Do you really want to delete the following route?", ]; \ No newline at end of file diff --git a/cli.php b/cli.php index 519f461..fb8f27a 100755 --- a/cli.php +++ b/cli.php @@ -7,6 +7,7 @@ include_once 'Core/core.php'; require_once 'Core/datetime.php'; include_once 'Core/constants.php'; +use Core\API\Request; use Core\Configuration\DatabaseScript; use Core\Driver\SQL\Column\Column; use Core\Driver\SQL\Condition\Compare; @@ -16,6 +17,7 @@ use Core\Driver\SQL\SQL; use Core\Objects\ConnectionData; // TODO: is this available in all installations? +use Core\Objects\Context; use JetBrains\PhpStorm\NoReturn; function printLine(string $line = ""): void { @@ -68,8 +70,13 @@ function connectSQL(): ?SQL { } function printHelp(array $argv): void { + global $registeredCommands; printLine("=== WebBase CLI tool ==="); - printLine("Usage: " . $argv[0]); + printLine("Usage: " . $argv[0] . " [action] "); + foreach ($registeredCommands as $command => $data) { + $description = $data["description"] ?? ""; + printLine(" - $command: $description"); + } } function applyPatch(\Core\Driver\SQL\SQL $sql, string $patchName): bool { @@ -660,7 +667,7 @@ function onImpersonate($argv): void { function onFrontend(array $argv): void { if (count($argv) < 3) { - _exit("Usage: cli.php frontend [options...]"); + _exit("Usage: cli.php frontend [options...]"); } $reactRoot = realpath(WEBROOT . "/react/"); @@ -800,17 +807,126 @@ function onFrontend(array $argv): void { } } +function onAPI(array $argv): void { + if (count($argv) < 3) { + _exit("Usage: cli.php api [options...]"); + } + + $action = $argv[2] ?? null; + if ($action === "ls") { + $endpoints = Request::getApiEndpoints(); + foreach ($endpoints as $endpoint => $class) { + $className = $class->getName(); + printLine(" - $className: $endpoint"); + } + // var_dump($endpoints); + } else if ($action === "add") { + echo "API-Name: "; + $methodNames = []; + $apiName = ucfirst(trim(fgets(STDIN))); + if (!preg_match("/[a-zA-Z_-]/", $apiName)) { + _exit("Invalid API-Name, should be [a-zA-Z_-]"); + } + + printLine("Do you want to add nested methods? Leave blank to skip."); + while (true) { + echo "Method name: "; + $methodName = ucfirst(trim(fgets(STDIN))); + if ($methodName) { + if (!preg_match("/[a-zA-Z_-]/", $methodName)) { + printLine("Invalid method name, should be [a-zA-Z_-]"); + } else if (in_array($methodName, $methodNames)) { + printLine("You already added this method."); + } else { + $methodNames[] = $methodName; + } + } else { + break; + } + } + + if (!empty($methodNames)) { + $fileName = "{$apiName}API.class.php"; + $methods = implode("\n\n", array_map(function ($methodName) use ($apiName) { + return " class $methodName extends {$apiName}API { + + public function __construct(Context \$context, bool \$externalCall = false) { + parent::__construct(\$context, \$externalCall, []); + // TODO: auto-generated method stub + } + + protected function _execute(): bool { + // TODO: auto-generated method stub + return \$this->success; + } + }"; + }, $methodNames)); + $content = "success; + } +} +"; + } + + $path = implode(DIRECTORY_SEPARATOR, [WEBROOT, "Site", "API", $fileName]); + file_put_contents($path, $content); + printLine("Successfully created API-template: $path"); + } else { + _exit("Usage: cli.php api [options...]"); + } +} + $argv = $_SERVER['argv']; $registeredCommands = [ - "help" => ["handler" => "printHelp"], - "db" => ["handler" => "handleDatabase"], - "routes" => ["handler" => "onRoutes"], - "maintenance" => ["handler" => "onMaintenance"], - "test" => ["handler" => "onTest"], - "mail" => ["handler" => "onMail"], - "settings" => ["handler" => "onSettings"], - "impersonate" => ["handler" => "onImpersonate", "requiresDocker" => true], - "frontend" => ["handler" => "onFrontend"], + "help" => ["handler" => "printHelp", "description" => "prints this help page"], + "db" => ["handler" => "handleDatabase", "description" => "database actions like importing, exporting and shell"], + "routes" => ["handler" => "onRoutes", "description" => "view and modify routes"], + "maintenance" => ["handler" => "onMaintenance", "description" => "toggle maintenance mode"], + "test" => ["handler" => "onTest", "description" => "run unit and integration tests", "requiresDocker" => true], + "mail" => ["handler" => "onMail", "description" => "send mails and process the pipeline"], + "settings" => ["handler" => "onSettings", "description" => "change and view settings"], + "impersonate" => ["handler" => "onImpersonate", "description" => "create a session and print cookies and csrf tokens", "requiresDocker" => true], + "frontend" => ["handler" => "onFrontend", "description" => "build and manage frontend modules"], + "api" => ["handler" => "onAPI", "description" => "view and create API endpoints"], ]; diff --git a/react/admin-panel/src/views/group/group-edit.js b/react/admin-panel/src/views/group/group-edit.js index 04d59c5..aae2eb1 100644 --- a/react/admin-panel/src/views/group/group-edit.js +++ b/react/admin-panel/src/views/group/group-edit.js @@ -73,7 +73,7 @@ export default function EditGroupView(props) { setMembers(res.users); pagination.update(res.pagination); } else { - props.showDialog(res.msg, "Error fetching group members"); + props.showDialog(res.msg, L("account.fetch_group_members_error")); return null; } }); @@ -85,47 +85,16 @@ export default function EditGroupView(props) { let newMembers = members.filter(u => u.id !== userId); setMembers(newMembers); } else { - props.showDialog(data.msg, "Error removing group member"); + props.showDialog(data.msg, L("account.remove_group_member_error")); } }); }, [api, groupId, members]); - const onSave = useCallback(() => { - setSaving(true); - if (isNewGroup) { - api.createGroup(group.name, group.color).then(data => { - setSaving(false); - if (!data.success) { - props.showDialog(data.msg, "Error creating group"); - } else { - navigate(`/admin/group/${data.id}`) - } - }); - } else { - api.updateGroup(groupId, group.name, group.color).then(data => { - setSaving(false); - if (!data.success) { - props.showDialog(data.msg, "Error updating group"); - } - }); - } - }, [api, groupId, isNewGroup, group]); - - const onSearchUser = useCallback((async (query) => { - let data = await api.searchUser(query); - if (!data.success) { - props.showDialog(data.msg, "Error searching users"); - return []; - } - - return data.users; - }), [api]); - const onAddMember = useCallback(() => { if (selectedUser) { api.addGroupMember(groupId, selectedUser.id).then(data => { if (!data.success) { - props.showDialog(data.msg, "Error adding member"); + props.showDialog(data.msg, L("account.add_group_member_error")); } else { let newMembers = [...members]; newMembers.push(selectedUser); @@ -136,10 +105,41 @@ export default function EditGroupView(props) { } }, [api, groupId, selectedUser]) + const onSave = useCallback(() => { + setSaving(true); + if (isNewGroup) { + api.createGroup(group.name, group.color).then(data => { + setSaving(false); + if (!data.success) { + props.showDialog(data.msg, L("account.create_group_error")); + } else { + navigate(`/admin/group/${data.id}`) + } + }); + } else { + api.updateGroup(groupId, group.name, group.color).then(data => { + setSaving(false); + if (!data.success) { + props.showDialog(data.msg, L("account.update_group_error")); + } + }); + } + }, [api, groupId, isNewGroup, group]); + + const onSearchUser = useCallback((async (query) => { + let data = await api.searchUser(query); + if (!data.success) { + props.showDialog(data.msg, L("account.search_users_error")); + return []; + } + + return data.users; + }), [api]); + const onDeleteGroup = useCallback(() => { api.deleteGroup(groupId).then(data => { if (!data.success) { - props.showDialog(data.msg, "Error deleting group"); + props.showDialog(data.msg, L("account.delete_group_error")); } else { navigate("/admin/groups"); } @@ -150,6 +150,15 @@ export default function EditGroupView(props) { onFetchGroup(); }, []); + const complementaryColor = (color) => { + if (color.startsWith("#")) { + color = color.substring(1); + } + + let numericValue = parseInt(color, 16); + return "#" + (0xFFFFFF - numericValue).toString(16); + } + if (group === null) { return } @@ -221,8 +230,8 @@ export default function EditGroupView(props) { variant={"outlined"} color={"secondary"} onClick={() => setDialogData({ open: true, - title: L("Delete Group"), - message: L("Do you really want to delete this group? This action cannot be undone."), + title: L("account.delete_group_title"), + message: L("account.delete_group_text"), onOption: option => option === 0 && onDeleteGroup() })}> {L("general.delete")} @@ -258,8 +267,8 @@ export default function EditGroupView(props) { disabled: !api.hasPermission("groups/removeMember"), onClick: (entry) => setDialogData({ open: true, - title: L("Remove member"), - message: sprintf(L("Do you really want to remove user '%s' from this group?"), entry.fullName || entry.name), + title: L("account.remove_group_member_title"), + message: sprintf(L("account.remove_group_member_text"), entry.fullName || entry.name), onOption: (option) => option === 0 && onRemoveMember(entry.id) }) } @@ -270,8 +279,8 @@ export default function EditGroupView(props) { variant={"outlined"} disabled={!api.hasPermission("groups/addMember")} onClick={() => setDialogData({ open: true, - title: L("Add member"), - message: "Search a user to add to the group", + title: L("account.add_group_member_title"), + message: L("account.add_group_member_text"), inputs: [ { type: "custom", name: "search", element: SearchField, diff --git a/react/admin-panel/src/views/route/route-list.js b/react/admin-panel/src/views/route/route-list.js index bde2131..f6c7954 100644 --- a/react/admin-panel/src/views/route/route-list.js +++ b/react/admin-panel/src/views/route/route-list.js @@ -193,10 +193,10 @@ export default function RouteListView(props) { color={"secondary"} onClick={() => setDialogData({ open: true, - title: L("Delete Route"), - message: L("Do you really want to delete the following route?"), + title: L("routes.delete_route_title"), + message: L("routes.delete_route_text"), inputs: [ - { type: "text", value: route.pattern, disabled: true} + { type: "text", name: "pattern", value: route.pattern, disabled: true} ], options: [L("general.ok"), L("general.cancel")], onOption: btn => btn === 0 && onDeleteRoute(route.id) diff --git a/test/Request.test.php b/test/Request.test.php index 137ac4f..8c5bacc 100644 --- a/test/Request.test.php +++ b/test/Request.test.php @@ -56,8 +56,8 @@ class RequestTest extends \PHPUnit\Framework\TestCase { } foreach (self::FUNCTION_OVERRIDES as $functionName) { - runkit7_function_rename($functionName, "__orig_${functionName}_impl"); - runkit7_function_rename("__new_${functionName}_impl", $functionName); + runkit7_function_rename($functionName, "__orig_{$functionName}_impl"); + runkit7_function_rename("__new_{$functionName}_impl", $functionName); } } @@ -65,7 +65,7 @@ class RequestTest extends \PHPUnit\Framework\TestCase { RequestTest::$CONTEXT->getSQL()?->close(); foreach (self::FUNCTION_OVERRIDES as $functionName) { runkit7_function_remove($functionName); - runkit7_function_rename("__orig_${functionName}_impl", $functionName); + runkit7_function_rename("__orig_{$functionName}_impl", $functionName); } } @@ -99,6 +99,7 @@ class RequestTest extends \PHPUnit\Framework\TestCase { $this->assertTrue($this->simulateRequest($allMethodsAllowed, "POST"), $allMethodsAllowed->getLastError()); $this->assertFalse($this->simulateRequest($allMethodsAllowed, "PUT"), $allMethodsAllowed->getLastError()); $this->assertFalse($this->simulateRequest($allMethodsAllowed, "DELETE"), $allMethodsAllowed->getLastError()); + $this->assertFalse($this->simulateRequest($allMethodsAllowed, "NONEXISTENT"), $allMethodsAllowed->getLastError()); $this->assertTrue($this->simulateRequest($allMethodsAllowed, "OPTIONS"), $allMethodsAllowed->getLastError()); $this->assertEquals(204, self::$SENT_STATUS_CODE); $this->assertEquals(["Allow" => "OPTIONS, GET, POST"], self::$SENT_HEADERS); @@ -156,6 +157,10 @@ abstract class TestRequest extends Request { protected function _execute(): bool { return true; } + + public static function getEndpoint(string $prefix = ""): ?string { + return "test"; + } } class RequestAllMethods extends TestRequest {