diff --git a/Core/API/RoutesAPI.class.php b/Core/API/RoutesAPI.class.php index 2b5a0f3..8f6cb60 100644 --- a/Core/API/RoutesAPI.class.php +++ b/Core/API/RoutesAPI.class.php @@ -5,6 +5,7 @@ namespace Core\API { use Core\API\Routes\GenerateCache; use Core\Objects\Context; use Core\Objects\DatabaseEntity\Route; + use Core\Objects\Router\ApiRoute; abstract class RoutesAPI extends Request { @@ -24,6 +25,8 @@ namespace Core\API { return false; } else if ($route === null) { return $this->createError("Route not found"); + } else if ($route instanceof ApiRoute) { + return $this->createError("This route cannot be modified"); } $route->setActive($active); @@ -71,6 +74,7 @@ namespace Core\API\Routes { use Core\Objects\Context; use Core\Objects\DatabaseEntity\Group; use Core\Objects\DatabaseEntity\Route; + use Core\Objects\Router\ApiRoute; use Core\Objects\Router\Router; class Fetch extends RoutesAPI { @@ -87,10 +91,7 @@ namespace Core\API\Routes { $this->success = ($routes !== FALSE); if ($this->success) { - $this->result["routes"] = []; - foreach ($routes as $route) { - $this->result["routes"][$route->getId()] = $route->jsonSerialize(); - } + $this->result["routes"] = $routes; } return $this->success; @@ -307,7 +308,6 @@ namespace Core\API\Routes { parent::__construct($context, $externalCall, array( "id" => new Parameter("id", Parameter::TYPE_INT) )); - $this->isPublic = false; } public function _execute(): bool { @@ -319,6 +319,8 @@ namespace Core\API\Routes { return $this->createError("Error fetching route: " . $sql->getLastError()); } else if ($route === null) { return $this->createError("Route not found"); + } else if ($route instanceof ApiRoute) { + return $this->createError("This route cannot be deleted"); } $this->success = $route->delete($sql) !== false; @@ -336,7 +338,6 @@ namespace Core\API\Routes { parent::__construct($context, $externalCall, array( "id" => new Parameter("id", Parameter::TYPE_INT) )); - $this->isPublic = false; } public function _execute(): bool { @@ -354,7 +355,6 @@ namespace Core\API\Routes { parent::__construct($context, $externalCall, array( "id" => new Parameter("id", Parameter::TYPE_INT) )); - $this->isPublic = false; } public function _execute(): bool { @@ -373,7 +373,6 @@ namespace Core\API\Routes { public function __construct(Context $context, bool $externalCall = false) { parent::__construct($context, $externalCall, []); - $this->isPublic = false; $this->router = null; } @@ -408,7 +407,10 @@ namespace Core\API\Routes { } public static function getDefaultACL(Insert $insert): void { - $insert->addRow(self::getEndpoint(), [Group::ADMIN, Group::SUPPORT], "Allows users to regenerate the routing cache", true); + $insert->addRow(self::getEndpoint(), + [Group::ADMIN, Group::SUPPORT], + "Allows users to regenerate the routing cache", true + ); } } diff --git a/Core/Objects/Router/DocumentRoute.class.php b/Core/Objects/Router/DocumentRoute.class.php index bb2b8c1..b00cdd8 100644 --- a/Core/Objects/Router/DocumentRoute.class.php +++ b/Core/Objects/Router/DocumentRoute.class.php @@ -5,6 +5,7 @@ namespace Core\Objects\Router; use Core\Driver\SQL\SQL; use Core\Elements\Document; use Core\Objects\Context; +use Core\Objects\DatabaseEntity\Attribute\Transient; use Core\Objects\DatabaseEntity\Route; use Core\Objects\Search\Searchable; use Core\Objects\Search\SearchQuery; @@ -15,7 +16,10 @@ class DocumentRoute extends Route { use Searchable; + #[Transient] private array $args; + + #[Transient] private ?\ReflectionClass $reflectionClass = null; public function __construct(string $pattern, bool $exact, string $className, ...$args) { diff --git a/Core/Objects/Router/RedirectRoute.class.php b/Core/Objects/Router/RedirectRoute.class.php index cc2e880..1c0ea39 100644 --- a/Core/Objects/Router/RedirectRoute.class.php +++ b/Core/Objects/Router/RedirectRoute.class.php @@ -2,11 +2,13 @@ namespace Core\Objects\Router; +use Core\Objects\DatabaseEntity\Attribute\Transient; use Core\Objects\DatabaseEntity\Route; use JetBrains\PhpStorm\Pure; class RedirectRoute extends Route { + #[Transient] private int $code; public function __construct(string $type, string $pattern, bool $exact, string $destination, int $code = 307) { diff --git a/Core/Objects/Router/StaticFileRoute.class.php b/Core/Objects/Router/StaticFileRoute.class.php index 80c34e7..8671a4a 100644 --- a/Core/Objects/Router/StaticFileRoute.class.php +++ b/Core/Objects/Router/StaticFileRoute.class.php @@ -4,6 +4,7 @@ namespace Core\Objects\Router; use Core\Driver\SQL\SQL; use Core\Objects\Context; +use Core\Objects\DatabaseEntity\Attribute\Transient; use Core\Objects\DatabaseEntity\Route; use Core\Objects\Search\Searchable; use Core\Objects\Search\SearchQuery; @@ -14,6 +15,7 @@ class StaticFileRoute extends Route { use Searchable; + #[Transient] private int $code; public function __construct(string $pattern, bool $exact, string $path, int $code = 200) { diff --git a/Core/Objects/Router/StaticRoute.class.php b/Core/Objects/Router/StaticRoute.class.php index 6fa509d..553700f 100644 --- a/Core/Objects/Router/StaticRoute.class.php +++ b/Core/Objects/Router/StaticRoute.class.php @@ -2,11 +2,15 @@ namespace Core\Objects\Router; +use Core\Objects\DatabaseEntity\Attribute\Transient; use Core\Objects\DatabaseEntity\Route; class StaticRoute extends Route { + #[Transient] private string $data; + + #[Transient] private int $code; public function __construct(string $pattern, bool $exact, string $data, int $code = 200) { diff --git a/react/admin-panel/src/AdminDashboard.jsx b/react/admin-panel/src/AdminDashboard.jsx index d846fab..1e0e14b 100644 --- a/react/admin-panel/src/AdminDashboard.jsx +++ b/react/admin-panel/src/AdminDashboard.jsx @@ -20,6 +20,7 @@ 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")); +const RouteListView = lazy(() => import("./views/routes")); export default function AdminDashboard(props) { @@ -79,6 +80,7 @@ export default function AdminDashboard(props) { }/> }/> }/> + }/> } /> diff --git a/react/admin-panel/src/elements/sidebar.js b/react/admin-panel/src/elements/sidebar.js index 658e3fb..da8b2aa 100644 --- a/react/admin-panel/src/elements/sidebar.js +++ b/react/admin-panel/src/elements/sidebar.js @@ -36,7 +36,7 @@ export default function Sidebar(props) { "name": "admin.groups", "icon": "users-cog" }, - "pages": { + "routes": { "name": "admin.page_routes", "icon": "copy", }, diff --git a/react/admin-panel/src/views/access-control-list.js b/react/admin-panel/src/views/access-control-list.js index bf20f64..8b5aecc 100644 --- a/react/admin-panel/src/views/access-control-list.js +++ b/react/admin-panel/src/views/access-control-list.js @@ -12,12 +12,11 @@ import { TableContainer, TableHead, TableRow, - IconButton + IconButton, styled } from "@material-ui/core"; import {Add, Delete, Edit, Refresh} from "@material-ui/icons"; import {USER_GROUP_ADMIN} from "shared/constants"; import Dialog from "shared/elements/dialog"; -import {TableFooter} from "@mui/material"; export default function AccessControlList(props) { @@ -74,18 +73,22 @@ export default function AccessControlList(props) { const onChangePermission = useCallback((methodIndex, groupId, selected) => { let newGroups = null; let currentGroups = acl[methodIndex].groups; - let groupIndex = currentGroups.indexOf(groupId); - if (!selected) { - if (currentGroups.length === 0) { - // it was an "everyone permission" before - newGroups = groups.filter(group => group.id !== groupId).map(group => group.id); - } else if (groupIndex !== -1 && currentGroups.length > 1) { + if (groupId === null) { + newGroups = []; + } else { + let groupIndex = currentGroups.indexOf(groupId); + if (!selected) { + if (currentGroups.length === 0) { + // it was an "everyone permission" before + newGroups = groups.filter(group => group.id !== groupId).map(group => group.id); + } else if (groupIndex !== -1) { + newGroups = [...currentGroups]; + newGroups.splice(groupIndex, 1); + } + } else if (groupIndex === -1) { newGroups = [...currentGroups]; - newGroups.splice(groupIndex, 1); + newGroups.push(groupId); } - } else if (groupIndex === -1) { - newGroups = [...currentGroups]; - newGroups.push(groupId); } if (newGroups !== null) { @@ -94,6 +97,7 @@ export default function AccessControlList(props) { let newACL = [...acl]; newACL[methodIndex].groups = newGroups; setACL(newACL); + props.api.fetchUser(); } else { props.showDialog("Error updating permission: " + data.msg); } @@ -106,6 +110,7 @@ export default function AccessControlList(props) { if (data.success) { let newACL = acl.filter(acl => acl.method.toLowerCase() !== method.toLowerCase()); setACL(newACL); + props.api.fetchUser(); } else { props.showDialog("Error deleting permission: " + data.msg); } @@ -119,6 +124,7 @@ export default function AccessControlList(props) { newACL.push({method: inputData.method, groups: groups, description: inputData.description}); newACL = newACL.sort((a, b) => a.method.localeCompare(b.method)) setACL(newACL); + props.api.fetchUser(); } else { props.showDialog("Error updating permission: " + data.msg); } @@ -180,8 +186,13 @@ export default function AccessControlList(props) { + + onChangePermission(index, null, e.target.checked)} + disabled={isRestricted(permission.method)} /> + {groups.map(group => - onChangePermission(index, group.id, e.target.checked)} disabled={isRestricted(permission.method)} /> )} @@ -192,6 +203,11 @@ export default function AccessControlList(props) { return <>{rows} } + const BorderedColumn = styled(TableCell)({ + borderLeft: "1px dotted #666", + borderRight: "1px dotted #666", + }); + return <>
@@ -244,23 +260,22 @@ export default function AccessControlList(props) {
-
- - - - - {L("permission")} - { groups.map(group => - {group.name} - ) } - - - - - -
-
-
+ + + + + {L("permission")} + {L("everyone")} + { groups.map(group => + {group.name} + ) } + + + + + +
+
setDialogData({open: false})} title={dialogData.title} @@ -268,6 +283,5 @@ export default function AccessControlList(props) { onOption={dialogData.onOption} inputs={dialogData.inputs} options={[L("general.ok"), L("general.cancel")]} /> - } \ No newline at end of file diff --git a/react/admin-panel/src/views/routes.js b/react/admin-panel/src/views/routes.js new file mode 100644 index 0000000..f88350c --- /dev/null +++ b/react/admin-panel/src/views/routes.js @@ -0,0 +1,226 @@ +import {Link, useNavigate} from "react-router-dom"; +import { + Paper, + styled, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + IconButton, Button, Checkbox +} from "@material-ui/core"; +import {useCallback, useContext, useEffect, useState} from "react"; +import {LocaleContext} from "shared/locale"; +import {Add, Cached, Delete, Edit, Refresh} from "@material-ui/icons"; +import {Quiz} from "@mui/icons-material"; +import Dialog from "shared/elements/dialog"; + + +export default function RouteListView(props) { + + // meta + const api = props.api; + const {translate: L, requestModules, currentLocale} = useContext(LocaleContext); + const navigate = useNavigate(); + + // data + const [fetchRoutes, setFetchRoutes] = useState(true); + const [routes, setRoutes] = useState({}); + + // ui + const [dialogData, setDialogData] = useState({show: false}); + const [isGeneratingCache, setGeneratingCache] = useState(false); + + const onFetchRoutes = useCallback((force = false) => { + if (force || fetchRoutes) { + setFetchRoutes(false); + props.api.fetchRoutes().then(res => { + if (!res.success) { + props.showDialog(res.msg, "Error fetching routes"); + navigate("/admin/dashboard"); + } else { + setRoutes(res.routes); + } + }); + } + }, [fetchRoutes]); + + useEffect(() => { + onFetchRoutes(); + }, []); + + useEffect(() => { + requestModules(props.api, ["general"], currentLocale).then(data => { + if (!data.success) { + props.showDialog("Error fetching translations: " + data.msg); + } + }); + }, [currentLocale]); + + const onToggleRoute = useCallback((id, active) => { + if (active) { + props.api.enableRoute(id).then(data => { + if (!data.success) { + props.showDialog(data.msg, L("Error enabling route")); + } else { + setRoutes({...routes, [id]: { ...routes[id], active: true }}); + } + }); + } else { + props.api.disableRoute(id).then(data => { + if (!data.success) { + props.showDialog(data.msg, L("Error enabling route")); + } else { + setRoutes({...routes, [id]: { ...routes[id], active: false }}); + } + }); + } + }, [routes]); + + const onDeleteRoute = useCallback(id => { + props.api.deleteRoute(id).then(data => { + if (!data.success) { + props.showDialog(data.msg, L("Error removing route")); + } else { + let newRoutes = { ...routes }; + delete newRoutes[id]; + setRoutes(newRoutes); + } + }) + }, [routes]); + + const onRegenerateCache = useCallback(() => { + if (!isGeneratingCache) { + setGeneratingCache(true); + props.api.regenerateRouterCache().then(data => { + if (!data.success) { + props.showDialog(data.msg, L("Error regenerating router cache")); + setGeneratingCache(false); + } else { + setDialogData({ + open: true, + title: L("general.success"), + message: L("Router cache successfully regenerated"), + onClose: () => setGeneratingCache(false) + }) + } + }); + } + }, [isGeneratingCache]); + + const RouteTableRow = styled(TableRow)((props) => ({ + "& td": { + fontFamily: "monospace" + } + })); + + const BoolCell = (props) => props.checked ? L("general.yes") : L("general.no") + + return <> +
+
+
+
+

Routes

+
+
+
    +
  1. Home
  2. +
  3. Routes
  4. +
+
+
+
+
+
+
+
+
+ + + +
+
+
+ + + + + {L("general.id")} + {L("Route")} + {L("Type")} + {L("Target")} + {L("Extra")} + {L("Active")} + {L("Exact")} + {L("general.controls")} + + + + {Object.entries(routes).map(([id, route]) => + + {route.id} + {route.pattern} + {route.type} + {route.target} + {route.extra} + + onToggleRoute(route.id, e.target.checked)} /> + + + + navigate("/admin/routes/" + id)}> + + + setDialogData({ + open: true, + title: L("Delete Route"), + message: L("Do you really want to delete the following route?"), + inputs: [ + { type: "text", value: route.pattern, disabled: true} + ], + options: [L("general.ok"), L("general.cancel")], + onOption: btn => btn === 0 && onDeleteRoute(route.id) + })}> + + + + + + + + )} + +
+
+ { setDialogData({open: false}); dialogData.onClose && dialogData.onClose() }} + title={dialogData.title} + message={dialogData.message} + onOption={dialogData.onOption} + inputs={dialogData.inputs} + options={[L("general.ok"), L("general.cancel")]} /> + +} \ No newline at end of file diff --git a/react/shared/api.js b/react/shared/api.js index cd6b541..b0e4bfb 100644 --- a/react/shared/api.js +++ b/react/shared/api.js @@ -213,14 +213,30 @@ export default class API { } /** RoutesAPI **/ - async getRoutes() { + async fetchRoutes() { return this.apiCall("routes/fetch"); } + async enableRoute(id) { + return this.apiCall("routes/enable", { id: id }); + } + + async disableRoute(id) { + return this.apiCall("routes/disable", { id: id }); + } + + async deleteRoute(id) { + return this.apiCall("routes/remove", { id: id }); + } + async saveRoutes(routes) { return this.apiCall("routes/save", { routes: routes }); } + async regenerateRouterCache() { + return this.apiCall("routes/generateCache"); + } + /** GroupAPI **/ async createGroup(name, color) { return this.apiCall("groups/create", { name: name, color: color });