diff --git a/Core/API/PermissionAPI.class.php b/Core/API/PermissionAPI.class.php
index 21d2921..86297d4 100644
--- a/Core/API/PermissionAPI.class.php
+++ b/Core/API/PermissionAPI.class.php
@@ -34,6 +34,7 @@ namespace Core\API\Permission {
use Core\API\Parameter\StringType;
use Core\API\PermissionAPI;
use Core\Driver\SQL\Column\Column;
+ use Core\Driver\SQL\Condition\CondIn;
use Core\Driver\SQL\Condition\CondLike;
use Core\Driver\SQL\Query\Insert;
use Core\Driver\SQL\Strategy\UpdateStrategy;
@@ -168,25 +169,25 @@ namespace Core\API\Permission {
return $this->createError("This method cannot be updated.");
}
- $groups = $this->getParam("groups");
- if (!empty($groups)) {
- sort($groups);
- $availableGroups = Group::findAll($sql);
- foreach ($groups as $groupId) {
+ $groupIds = array_unique($this->getParam("groups"));
+ if (!empty($groupIds)) {
+ sort($groupIds);
+ $availableGroups = Group::findAll($sql, new CondIn(new Column("id"), $groupIds));
+ foreach ($groupIds as $groupId) {
if (!isset($availableGroups[$groupId])) {
- return $this->createError("Unknown group id: $groupId");
+ return $this->createError("Group with id=$groupId does not exist.");
}
}
}
if ($description === null) {
$updateQuery = $sql->insert("ApiPermission", ["method", "groups", "isCore"])
- ->onDuplicateKeyStrategy(new UpdateStrategy(["method"], ["groups" => $groups]))
- ->addRow($method, $groups, false);
+ ->onDuplicateKeyStrategy(new UpdateStrategy(["method"], ["groups" => $groupIds]))
+ ->addRow($method, $groupIds, false);
} else {
$updateQuery = $sql->insert("ApiPermission", ["method", "groups", "isCore", "description"])
- ->onDuplicateKeyStrategy(new UpdateStrategy(["method"], ["groups" => $groups, "description" => $description]))
- ->addRow($method, $groups, false, $description);
+ ->onDuplicateKeyStrategy(new UpdateStrategy(["method"], ["groups" => $groupIds, "description" => $description]))
+ ->addRow($method, $groupIds, false, $description);
}
$this->success = $updateQuery->execute() !== false;
diff --git a/Core/API/RoutesAPI.class.php b/Core/API/RoutesAPI.class.php
index 6a00413..2b5a0f3 100644
--- a/Core/API/RoutesAPI.class.php
+++ b/Core/API/RoutesAPI.class.php
@@ -67,19 +67,11 @@ namespace Core\API\Routes {
use Core\API\Parameter\Parameter;
use Core\API\Parameter\StringType;
use Core\API\RoutesAPI;
- use Core\Driver\SQL\Condition\Compare;
- use Core\Driver\SQL\Condition\CondBool;
use Core\Driver\SQL\Query\Insert;
- use Core\Driver\SQL\Query\StartTransaction;
use Core\Objects\Context;
use Core\Objects\DatabaseEntity\Group;
use Core\Objects\DatabaseEntity\Route;
- use Core\Objects\Router\DocumentRoute;
- use Core\Objects\Router\RedirectPermanentlyRoute;
- use Core\Objects\Router\RedirectRoute;
- use Core\Objects\Router\RedirectTemporaryRoute;
use Core\Objects\Router\Router;
- use Core\Objects\Router\StaticFileRoute;
class Fetch extends RoutesAPI {
@@ -419,5 +411,39 @@ namespace Core\API\Routes {
$insert->addRow(self::getEndpoint(), [Group::ADMIN, Group::SUPPORT], "Allows users to regenerate the routing cache", true);
}
}
+
+ class Check extends RoutesAPI {
+ public function __construct(Context $context, bool $externalCall) {
+ parent::__construct($context, $externalCall, [
+ "id" => new Parameter("id", Parameter::TYPE_INT),
+ "path" => new StringType("path")
+ ]);
+ }
+
+ protected function _execute(): bool {
+ $sql = $this->context->getSQL();
+ $routeId = $this->getParam("id");
+ $route = Route::find($sql, $routeId);
+ if ($route === false) {
+ $this->lastError = $sql->getLastError();
+ return false;
+ } else if ($route === null) {
+ return $this->createError("Route not found");
+ }
+
+ $this->success = true;
+ $path = $this->getParam("path");
+ $this->result["match"] = $route->match($path);
+ return $this->success;
+ }
+
+ public static function getDefaultACL(Insert $insert): void {
+ $insert->addRow("routes/check",
+ [Group::ADMIN, Group::MODERATOR],
+ "Users with this permission can see, if a route is matched with the given path for debugging purposes",
+ true
+ );
+ }
+ }
}
diff --git a/Core/API/UserAPI.class.php b/Core/API/UserAPI.class.php
index 0a2aee3..a08883f 100644
--- a/Core/API/UserAPI.class.php
+++ b/Core/API/UserAPI.class.php
@@ -183,14 +183,14 @@ namespace Core\API\User {
$groups = [];
$sql = $this->context->getSQL();
- // TODO: Currently low-privileged users can request any groups here, so a simple privilege escalation is possible. \
- // what do? limit access to user/create to admins only?
$requestedGroups = array_unique($this->getParam("groups"));
if (!empty($requestedGroups)) {
- $groups = Group::findAll($sql, new CondIn(new Column("id"), $requestedGroups));
+ $availableGroups = Group::findAll($sql, new CondIn(new Column("id"), $requestedGroups));
foreach ($requestedGroups as $groupId) {
- if (!isset($groups[$groupId])) {
+ if (!isset($availableGroups[$groupId])) {
return $this->createError("Group with id=$groupId does not exist.");
+ } else if ($groupId === Group::ADMIN && !$this->context->getUser()->hasGroup(Group::ADMIN)) {
+ return $this->createError("You cannot create users with administrator groups.");
}
}
}
@@ -632,7 +632,7 @@ namespace Core\API\User {
public function __construct(Context $context, bool $externalCall = false) {
$parameters = array(
"username" => new StringType("username", 32),
- 'email' => new Parameter('email', Parameter::TYPE_EMAIL),
+ "email" => new Parameter("email", Parameter::TYPE_EMAIL),
"password" => new StringType("password"),
"confirmPassword" => new StringType("confirmPassword"),
);
@@ -746,7 +746,7 @@ namespace Core\API\User {
'fullName' => new StringType('fullName', 64, true, NULL),
'email' => new Parameter('email', Parameter::TYPE_EMAIL, true, NULL),
'password' => new StringType('password', -1, true, NULL),
- 'groups' => new Parameter('groups', Parameter::TYPE_ARRAY, true, NULL),
+ 'groups' => new ArrayType('groups', Parameter::TYPE_INT, true, true, NULL),
'confirmed' => new Parameter('confirmed', Parameter::TYPE_BOOLEAN, true, NULL)
));
@@ -777,19 +777,18 @@ namespace Core\API\User {
$groupIds = array();
if (!is_null($groups)) {
- $param = new Parameter('groupId', Parameter::TYPE_INT);
-
- foreach ($groups as $groupId) {
- if (!$param->parseParam($groupId)) {
- $value = print_r($groupId, true);
- return $this->createError("Invalid Type for groupId in parameter groups: '$value' (Required: " . $param->getTypeName() . ")");
- }
-
- $groupIds[] = $param->value;
- }
-
+ $groupIds = array_unique($groups);
if ($id === $currentUser->getId() && !in_array(Group::ADMIN, $groupIds)) {
return $this->createError("Cannot remove Administrator group from own user.");
+ } else if (in_array(Group::ADMIN, $groupIds) && !$currentUser->hasGroup(Group::ADMIN)) {
+ return $this->createError("You cannot add the administrator group to other users.");
+ }
+
+ $availableGroups = Group::findAll($sql, new CondIn(new Column("id"), $groupIds));
+ foreach ($groupIds as $groupId) {
+ if (!isset($availableGroups[$groupId])) {
+ return $this->createError("Group with id=$groupId does not exist.");
+ }
}
}
diff --git a/Core/Documents/Security.class.php b/Core/Documents/Security.class.php
index 736a89d..1cc967a 100644
--- a/Core/Documents/Security.class.php
+++ b/Core/Documents/Security.class.php
@@ -8,6 +8,7 @@ use Core\Elements\Document;
use Core\Objects\DatabaseEntity\GpgKey;
use Core\Objects\DatabaseEntity\Language;
use Core\Objects\Router\Router;
+use DateTimeInterface;
// Source: https://www.rfc-editor.org/rfc/rfc9116
class Security extends Document {
@@ -42,12 +43,12 @@ class Security extends Document {
$lines = [
"# This project is based on the open-source framework hosted on https://github.com/rhergenreder/web-base",
- "# Any non-site specific issues can be reported via the github security reporting feature:",
+ "# Any non site-specific issues can be reported via the github security reporting feature:",
"# https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability",
"",
"Canonical: $baseUrl/.well-known/security.txt",
"Preferred-Languages: $languageCodes",
- "Expires: " . $expires->format(\DateTime::ATOM),
+ "Expires: " . $expires->format(DateTimeInterface::ATOM),
"",
];
@@ -85,6 +86,9 @@ class Security extends Document {
return "Error exporting public key: " . $res["msg"];
}
}
+ } else {
+ http_response_code(412);
+ return "No gpg key configured yet.";
}
}
diff --git a/Core/Localization/de_DE/admin.php b/Core/Localization/de_DE/admin.php
index 3fb5441..d1af028 100644
--- a/Core/Localization/de_DE/admin.php
+++ b/Core/Localization/de_DE/admin.php
@@ -5,8 +5,11 @@ return [
"dashboard" => "Dashboard",
"visitor_statistics" => "Besucherstatistiken",
"user_groups" => "Benutzer & Gruppen",
+ "users" => "Benutzer",
+ "groups" => "Gruppen",
"page_routes" => "Seiten & Routen",
"settings" => "Einstellungen",
+ "acl" => "Zugriffsberechtigung",
"logs" => "Logs",
"help" => "Hilfe",
];
\ No newline at end of file
diff --git a/Core/Localization/en_US/admin.php b/Core/Localization/en_US/admin.php
index 3e822b6..68a12e9 100644
--- a/Core/Localization/en_US/admin.php
+++ b/Core/Localization/en_US/admin.php
@@ -4,9 +4,12 @@ return [
"title" => "Administration",
"dashboard" => "Dashboard",
"visitor_statistics" => "Visitor Statistics",
- "user_groups" => "User & Groups",
+ "user_groups" => "Users & Groups",
+ "users" => "Users",
+ "groups" => "Groups",
"page_routes" => "Pages & Routes",
"settings" => "Settings",
+ "acl" => "Access Control",
"logs" => "Logs",
"help" => "Help",
];
\ No newline at end of file
diff --git a/Core/Objects/DatabaseEntity/Route.class.php b/Core/Objects/DatabaseEntity/Route.class.php
index 898b73d..45cb640 100644
--- a/Core/Objects/DatabaseEntity/Route.class.php
+++ b/Core/Objects/DatabaseEntity/Route.class.php
@@ -56,6 +56,10 @@ abstract class Route extends DatabaseEntity {
$this->active = true;
}
+ public function isActive(): bool {
+ return $this->active;
+ }
+
private static function parseParamType(?string $type): ?int {
if ($type === null || trim($type) === "") {
return null;
@@ -115,7 +119,7 @@ abstract class Route extends DatabaseEntity {
}
}
- public function match(string $url) {
+ public function match(string $url): bool|array {
# /test/{abc}/{param:?}/{xyz:int}/{aaa:int?}
$patternParts = self::getParts(Router::cleanURL($this->pattern, false));
diff --git a/Core/Objects/Router/DocumentRoute.class.php b/Core/Objects/Router/DocumentRoute.class.php
index 906456a..bb2b8c1 100644
--- a/Core/Objects/Router/DocumentRoute.class.php
+++ b/Core/Objects/Router/DocumentRoute.class.php
@@ -61,7 +61,7 @@ class DocumentRoute extends Route {
return true;
}
- public function match(string $url) {
+ public function match(string $url): bool|array {
$match = parent::match($url);
if ($match === false || !$this->loadClass()) {
return false;
diff --git a/react/admin-panel/src/AdminDashboard.jsx b/react/admin-panel/src/AdminDashboard.jsx
index d89da14..d846fab 100644
--- a/react/admin-panel/src/AdminDashboard.jsx
+++ b/react/admin-panel/src/AdminDashboard.jsx
@@ -16,8 +16,8 @@ import clsx from "clsx";
const Overview = lazy(() => import('./views/overview'));
const UserListView = lazy(() => import('./views/user/user-list'));
const UserEditView = lazy(() => import('./views/user/user-edit'));
-const GroupListView = lazy(() => import('./views/group-list'));
-const EditGroupView = lazy(() => import('./views/group-edit'));
+const GroupListView = lazy(() => import('./views/group/group-list'));
+const EditGroupView = lazy(() => import('./views/group/group-edit'));
const LogView = lazy(() => import("./views/log-view"));
const AccessControlList = lazy(() => import("./views/access-control-list"));
diff --git a/react/admin-panel/src/elements/sidebar.js b/react/admin-panel/src/elements/sidebar.js
index 6fe17a7..658e3fb 100644
--- a/react/admin-panel/src/elements/sidebar.js
+++ b/react/admin-panel/src/elements/sidebar.js
@@ -29,9 +29,13 @@ export default function Sidebar(props) {
"icon": "chart-bar",
},
"users": {
- "name": "admin.user_groups",
+ "name": "admin.users",
"icon": "users"
},
+ "groups": {
+ "name": "admin.groups",
+ "icon": "users-cog"
+ },
"pages": {
"name": "admin.page_routes",
"icon": "copy",
diff --git a/react/admin-panel/src/views/group-edit.js b/react/admin-panel/src/views/group/group-edit.js
similarity index 100%
rename from react/admin-panel/src/views/group-edit.js
rename to react/admin-panel/src/views/group/group-edit.js
diff --git a/react/admin-panel/src/views/group-list.js b/react/admin-panel/src/views/group/group-list.js
similarity index 69%
rename from react/admin-panel/src/views/group-list.js
rename to react/admin-panel/src/views/group/group-list.js
index a1ab3fa..9c3b3d2 100644
--- a/react/admin-panel/src/views/group-list.js
+++ b/react/admin-panel/src/views/group/group-list.js
@@ -5,30 +5,40 @@ import {DataColumn, DataTable, NumericColumn, StringColumn} from "shared/element
import {Button, IconButton} from "@material-ui/core";
import EditIcon from '@mui/icons-material/Edit';
import AddIcon from '@mui/icons-material/Add';
+import usePagination from "shared/hooks/pagination";
export default function GroupListView(props) {
+ // meta
const {translate: L, requestModules, currentLocale} = useContext(LocaleContext);
const navigate = useNavigate();
+ const pagination = usePagination();
+ const api = props.api;
+
+ // data
+ const [groups, setGroups] = useState([]);
useEffect(() => {
requestModules(props.api, ["general", "account"], currentLocale).then(data => {
if (!data.success) {
- alert(data.msg);
+ props.showDialog(data.msg, "Error fetching localization");
}
});
}, [currentLocale]);
const onFetchGroups = useCallback(async (page, count, orderBy, sortOrder) => {
- let res = await props.api.fetchGroups(page, count, orderBy, sortOrder);
- if (res.success) {
- return Promise.resolve([res.groups, res.pagination]);
- } else {
- props.showAlert("Error fetching groups", res.msg);
- return null;
- }
- }, []);
+
+ api.fetchGroups(page, count, orderBy, sortOrder).then((res) => {
+ if (res.success) {
+ setGroups(res.groups);
+ pagination.update(res.pagination);
+ } else {
+ props.showDialog(res.msg, "Error fetching groups");
+ return null;
+ }
+ });
+ }, [api, pagination]);
const actionColumn = (() => {
let column = new DataColumn(L("general.actions"), null, false);
@@ -69,10 +79,16 @@ export default function GroupListView(props) {
{L("general.create_new")}
-