diff --git a/app/classes/controllers/roles_controller.py b/app/classes/controllers/roles_controller.py index 60bf97b5..44a836c4 100644 --- a/app/classes/controllers/roles_controller.py +++ b/app/classes/controllers/roles_controller.py @@ -1,7 +1,8 @@ import logging +import typing as t from app.classes.models.roles import HelperRoles -from app.classes.models.server_permissions import PermissionsServers +from app.classes.models.server_permissions import PermissionsServers, RoleServers from app.classes.shared.helpers import Helpers logger = logging.getLogger(__name__) @@ -66,6 +67,90 @@ class RolesController: def add_role(role_name): return HelperRoles.add_role(role_name) + class RoleServerJsonType(t.TypedDict): + server_id: t.Union[str, int] + permissions: str + + @staticmethod + def get_server_ids_and_perms_from_role( + role_id: t.Union[str, int] + ) -> t.List[RoleServerJsonType]: + # FIXME: somehow retrieve only the server ids, not the whole servers + return [ + { + "server_id": role_servers.server_id.server_id, + "permissions": role_servers.permissions, + } + for role_servers in ( + RoleServers.select( + RoleServers.server_id, RoleServers.permissions + ).where(RoleServers.role_id == role_id) + ) + ] + + @staticmethod + def add_role_advanced( + name: str, + servers: t.Iterable[RoleServerJsonType], + ) -> int: + """Add a role with a name and a list of servers + + Args: + name (str): The new role's name + servers (t.List[RoleServerJsonType]): The new role's servers + + Returns: + int: The new role's ID + """ + role_id: t.Final[int] = HelperRoles.add_role(name) + for server in servers: + PermissionsServers.get_or_create( + role_id, server["server_id"], server["permissions"] + ) + return role_id + + @staticmethod + def update_role_advanced( + role_id: t.Union[str, int], + role_name: t.Optional[str], + servers: t.Optional[t.Iterable[RoleServerJsonType]], + ) -> None: + """Update a role with a name and a list of servers + + Args: + role_id (t.Union[str, int]): The ID of the role to be modified + role_name (t.Optional[str]): An optional new name for the role + servers (t.Optional[t.Iterable[RoleServerJsonType]]): An optional list of servers for the role + """ # pylint: disable=line-too-long + logger.debug(f"updating role {role_id} with advanced options") + + if servers is not None: + base_data = RolesController.get_role_with_servers(role_id) + + server_ids = {server["server_id"] for server in servers} + server_permissions_map = { + server["server_id"]: server["permissions"] for server in servers + } + + added_servers = server_ids.difference(set(base_data["servers"])) + removed_servers = set(base_data["servers"]).difference(server_ids) + logger.debug( + f"role: {role_id} +server:{added_servers} -server{removed_servers}" + ) + for server_id in added_servers: + PermissionsServers.get_or_create( + role_id, server_id, server_permissions_map[server_id] + ) + if len(removed_servers) != 0: + PermissionsServers.delete_roles_permissions(role_id, removed_servers) + if role_name is not None: + up_data = { + "role_name": role_name, + "last_update": Helpers.get_time_as_string(), + } + # TODO: do the last_update on the db side + HelperRoles.update_role(role_id, up_data) + def remove_role(self, role_id): role_data = RolesController.get_role_with_servers(role_id) PermissionsServers.delete_roles_permissions(role_id, role_data["servers"]) diff --git a/app/classes/controllers/users_controller.py b/app/classes/controllers/users_controller.py index 69e8d281..20faf4d6 100644 --- a/app/classes/controllers/users_controller.py +++ b/app/classes/controllers/users_controller.py @@ -76,7 +76,10 @@ class UsersController: }, "roles": { "type": "array", - "items": {"type": "string"}, + "items": { + "type": "string", + "minLength": 1, + }, }, "hints": {"type": "boolean"}, } diff --git a/app/classes/models/roles.py b/app/classes/models/roles.py index fffdd37f..e5afcd31 100644 --- a/app/classes/models/roles.py +++ b/app/classes/models/roles.py @@ -86,12 +86,10 @@ class HelperRoles: return Roles.update(up_data).where(Roles.role_id == role_id).execute() def remove_role(self, role_id): - with self.database.atomic(): - role = Roles.get(Roles.role_id == role_id) - return role.delete_instance() + return Roles.delete().where(Roles.role_id == role_id).execute() @staticmethod - def role_id_exists(role_id): + def role_id_exists(role_id) -> bool: if not HelperRoles.get_role(role_id): return False return True diff --git a/app/classes/models/server_permissions.py b/app/classes/models/server_permissions.py index 8cdcc0d7..6eab88be 100644 --- a/app/classes/models/server_permissions.py +++ b/app/classes/models/server_permissions.py @@ -179,9 +179,9 @@ class PermissionsServers: RoleServers.save(role_server) @staticmethod - def delete_roles_permissions(role_id, removed_servers=None): - if removed_servers is None: - removed_servers = {} + def delete_roles_permissions( + role_id: t.Union[str, int], removed_servers: t.Sequence[t.Union[str, int]] + ): return ( RoleServers.delete() .where(RoleServers.role_id == role_id) diff --git a/app/classes/models/servers.py b/app/classes/models/servers.py index cd91396a..6dae055a 100644 --- a/app/classes/models/servers.py +++ b/app/classes/models/servers.py @@ -190,6 +190,10 @@ class HelperServers: query = Servers.select() return DatabaseShortcuts.return_rows(query) + @staticmethod + def get_all_server_ids() -> t.List[int]: + return [server.server_id for server in Servers.select(Servers.server_id)] + @staticmethod def get_all_servers_stats(): servers = HelperServers.get_all_defined_servers() diff --git a/app/classes/shared/main_controller.py b/app/classes/shared/main_controller.py index 7c94cae1..41318279 100644 --- a/app/classes/shared/main_controller.py +++ b/app/classes/shared/main_controller.py @@ -5,7 +5,7 @@ import shutil import time import logging import tempfile -from typing import Union +import typing as t from peewee import DoesNotExist # TZLocal is set as a hidden import on win pipeline @@ -276,7 +276,7 @@ class Controller: except: return {"percent": 0, "total_files": 0} - def get_server_obj(self, server_id: Union[str, int]) -> Union[bool, Server]: + def get_server_obj(self, server_id: t.Union[str, int]) -> t.Union[bool, Server]: for server in self.servers_list: if str(server["server_id"]) == str(server_id): return server["server_obj"] @@ -297,6 +297,10 @@ class Controller: servers = HelperServers.get_all_defined_servers() return servers + @staticmethod + def get_all_server_ids() -> t.List[int]: + return HelperServers.get_all_server_ids() + def list_running_servers(self): running_servers = [] diff --git a/app/classes/web/base_api_handler.py b/app/classes/web/base_api_handler.py index 50385f90..24d7328d 100644 --- a/app/classes/web/base_api_handler.py +++ b/app/classes/web/base_api_handler.py @@ -1,7 +1,23 @@ +from typing import Awaitable, Callable, Optional from app.classes.web.base_handler import BaseHandler class BaseApiHandler(BaseHandler): + # {{{ Disable XSRF protection on API routes def check_xsrf_cookie(self) -> None: - # Disable XSRF protection on API routes pass + + # }}} + + # {{{ 405 Method Not Allowed as JSON + def _unimplemented_method(self, *_args: str, **_kwargs: str) -> None: + self.finish_json(405, {"status": "error", "error": "METHOD_NOT_ALLOWED"}) + + head = _unimplemented_method # type: Callable[..., Optional[Awaitable[None]]] + get = _unimplemented_method # type: Callable[..., Optional[Awaitable[None]]] + post = _unimplemented_method # type: Callable[..., Optional[Awaitable[None]]] + delete = _unimplemented_method # type: Callable[..., Optional[Awaitable[None]]] + patch = _unimplemented_method # type: Callable[..., Optional[Awaitable[None]]] + put = _unimplemented_method # type: Callable[..., Optional[Awaitable[None]]] + options = _unimplemented_method # type: Callable[..., Optional[Awaitable[None]]] + # }}} diff --git a/app/classes/web/panel_handler.py b/app/classes/web/panel_handler.py index df9940c0..4fa4982a 100644 --- a/app/classes/web/panel_handler.py +++ b/app/classes/web/panel_handler.py @@ -2,7 +2,7 @@ import time import datetime import os -from typing import Dict, Any, Tuple +import typing as t import json import logging import threading @@ -27,7 +27,7 @@ logger = logging.getLogger(__name__) class PanelHandler(BaseHandler): - def get_user_roles(self) -> Dict[str, list]: + def get_user_roles(self) -> t.Dict[str, list]: user_roles = {} for user_id in self.controller.users.get_all_user_ids(): user_roles_list = self.controller.users.get_user_roles_names(user_id) @@ -36,7 +36,7 @@ class PanelHandler(BaseHandler): user_roles[user_id] = user_roles_list return user_roles - def get_role_servers(self) -> set: + def get_role_servers(self) -> t.Set[int]: servers = set() for server in self.controller.list_defined_servers(): argument = int( @@ -50,7 +50,7 @@ class PanelHandler(BaseHandler): servers.add(server["server_id"]) return servers - def get_perms_quantity(self) -> Tuple[str, dict]: + def get_perms_quantity(self) -> t.Tuple[str, dict]: permissions_mask: str = "000" server_quantity: dict = {} for ( @@ -258,7 +258,7 @@ class PanelHandler(BaseHandler): user_order.remove(server_id) defined_servers = page_servers - page_data: Dict[str, Any] = { + page_data: t.Dict[str, t.Any] = { # todo: make this actually pull and compare version data "update_available": False, "serverTZ": get_localzone(), diff --git a/app/classes/web/routes/api/roles/index.py b/app/classes/web/routes/api/roles/index.py index 590183e5..acd1f833 100644 --- a/app/classes/web/routes/api/roles/index.py +++ b/app/classes/web/routes/api/roles/index.py @@ -1,6 +1,38 @@ +import typing as t +from jsonschema import ValidationError, validate +import orjson from playhouse.shortcuts import model_to_dict from app.classes.web.base_api_handler import BaseApiHandler +create_role_schema = { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + }, + "servers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "server_id": { + "type": "integer", + "minimum": 1, + }, + "permissions": { + "type": "string", + "pattern": "^[01]{8}$", # 8 bits, see EnumPermissionsServer + }, + }, + "required": ["server_id", "permissions"], + }, + }, + }, + "required": ["name"], + "additionalProperties": False, +} + class ApiRolesIndexHandler(BaseApiHandler): def get(self): @@ -21,7 +53,6 @@ class ApiRolesIndexHandler(BaseApiHandler): if not superuser: return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"}) - # TODO: permissions self.finish_json( 200, { @@ -31,3 +62,73 @@ class ApiRolesIndexHandler(BaseApiHandler): else [model_to_dict(r) for r in self.controller.roles.get_all_roles()], }, ) + + def post(self): + auth_data = self.authenticate_user() + if not auth_data: + return + ( + _, + _, + _, + superuser, + user, + ) = auth_data + + if not superuser: + return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"}) + + try: + data = orjson.loads(self.request.body) # pylint: disable=no-member + except orjson.decoder.JSONDecodeError as e: # pylint: disable=no-member + return self.finish_json( + 400, {"status": "error", "error": "INVALID_JSON", "error_data": str(e)} + ) + + try: + validate(data, create_role_schema) + except ValidationError as e: + return self.finish_json( + 400, + { + "status": "error", + "error": "INVALID_JSON_SCHEMA", + "error_data": str(e), + }, + ) + + role_name = data["name"] + + # Get the servers + servers_dict = {server["server_id"]: server for server in data["servers"]} + server_ids = ( + { + s + for s in ( + {server["server_id"] for server in data["servers"]} + & set(self.controller.get_all_server_ids()) + ) # Only allow existing servers + } + if "servers" in data + else set() + ) + servers: t.List[dict] = [servers_dict[server_id] for server_id in server_ids] + + if self.controller.roles.get_roleid_by_name(role_name) is not None: + return self.finish_json( + 400, {"status": "error", "error": "ROLE_NAME_ALREADY_EXISTS"} + ) + + role_id = self.controller.roles.add_role_advanced(role_name, servers) + + self.controller.management.add_to_audit_log( + user["user_id"], + f"created role {role_name} (RID:{role_id})", + server_id=0, + source_ip=self.get_remote_ip(), + ) + + self.finish_json( + 200, + {"status": "ok", "data": {"role_id": role_id}}, + ) diff --git a/app/classes/web/routes/api/roles/role/index.py b/app/classes/web/routes/api/roles/role/index.py index 7f4573d1..197c4f13 100644 --- a/app/classes/web/routes/api/roles/role/index.py +++ b/app/classes/web/routes/api/roles/role/index.py @@ -1,5 +1,39 @@ +from jsonschema import ValidationError, validate +import orjson from app.classes.web.base_api_handler import BaseApiHandler +modify_role_schema = { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + }, + "servers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "server_id": { + "type": "string", + "minLength": 1, + }, + "permissions": { + "type": "string", + "pattern": "^[01]{8}$", # 8 bits, see EnumPermissionsServer + }, + }, + "required": ["server_id", "permissions"], + }, + }, + }, + "anyOf": [ + {"required": ["name"]}, + {"required": ["servers"]}, + ], + "additionalProperties": False, +} + class ApiRolesRoleIndexHandler(BaseApiHandler): def get(self, role_id: str): @@ -17,7 +51,71 @@ class ApiRolesRoleIndexHandler(BaseApiHandler): if not superuser: return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"}) - # TODO: permissions + self.finish_json( + 200, + {"status": "ok", "data": self.controller.roles.get_role(role_id)}, + ) + + def delete(self, role_id: str): + auth_data = self.authenticate_user() + if not auth_data: + return + ( + _, + _, + _, + superuser, + _, + ) = auth_data + + if not superuser: + return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"}) + + self.controller.roles.remove_role(role_id) + + self.finish_json( + 200, + {"status": "ok", "data": role_id}, + ) + + def patch(self, role_id: str): + auth_data = self.authenticate_user() + if not auth_data: + return + ( + _, + _, + _, + superuser, + _, + ) = auth_data + + if not superuser: + return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"}) + + try: + data = orjson.loads(self.request.body) # pylint: disable=no-member + except orjson.decoder.JSONDecodeError as e: # pylint: disable=no-member + return self.finish_json( + 400, {"status": "error", "error": "INVALID_JSON", "error_data": str(e)} + ) + + try: + validate(data, modify_role_schema) + except ValidationError as e: + return self.finish_json( + 400, + { + "status": "error", + "error": "INVALID_JSON_SCHEMA", + "error_data": str(e), + }, + ) + + self.controller.roles.update_role_advanced( + role_id, data.get("role_name", None), data.get("servers", None) + ) + self.finish_json( 200, {"status": "ok", "data": self.controller.roles.get_role(role_id)}, diff --git a/app/classes/web/routes/api/roles/role/servers.py b/app/classes/web/routes/api/roles/role/servers.py index 97052a6a..b9b920ca 100644 --- a/app/classes/web/routes/api/roles/role/servers.py +++ b/app/classes/web/routes/api/roles/role/servers.py @@ -15,6 +15,9 @@ class ApiRolesRoleServersHandler(BaseApiHandler): _, ) = auth_data + # GET /api/v2/roles/role/servers?ids=true + get_only_ids = self.get_query_argument("ids", None) == "true" + if not superuser: return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"}) @@ -22,6 +25,8 @@ class ApiRolesRoleServersHandler(BaseApiHandler): 200, { "status": "ok", - "data": PermissionsServers.get_server_ids_from_role(role_id), + "data": PermissionsServers.get_server_ids_from_role(role_id) + if get_only_ids + else self.controller.roles.get_server_ids_and_perms_from_role(role_id), }, ) diff --git a/app/classes/web/routes/api/servers/index.py b/app/classes/web/routes/api/servers/index.py index 6604dd59..216fb687 100644 --- a/app/classes/web/routes/api/servers/index.py +++ b/app/classes/web/routes/api/servers/index.py @@ -40,6 +40,7 @@ new_server_schema = { "title": "Name", "type": "string", "examples": ["My Server"], + "minLength": 2, }, "stop_command": { "title": "Stop command", @@ -91,6 +92,7 @@ new_server_schema = { "type": "string", "default": "127.0.0.1", "examples": ["127.0.0.1"], + "minLength": 1, }, "port": { "title": "Port", @@ -111,6 +113,7 @@ new_server_schema = { "type": "string", "default": "127.0.0.1", "examples": ["127.0.0.1"], + "minLength": 1, }, "port": { "title": "Port", @@ -155,11 +158,13 @@ new_server_schema = { "title": "Server JAR Type", "type": "string", "examples": ["Paper"], + "minLength": 1, }, "version": { "title": "Server JAR Version", "type": "string", "examples": ["1.18.2"], + "minLength": 1, }, "mem_min": { "title": "Minimum JVM memory (in GiBs)", @@ -206,12 +211,14 @@ new_server_schema = { "description": "Absolute path to the old server", "type": "string", "examples": ["/var/opt/server"], + "minLength": 1, }, "jarfile": { "title": "JAR file", "description": "The JAR file relative to the previous path", "type": "string", "examples": ["paper.jar", "jars/vanilla-1.12.jar"], + "minLength": 1, }, "mem_min": { "title": "Minimum JVM memory (in GiBs)", @@ -259,18 +266,21 @@ new_server_schema = { "description": "Absolute path to the ZIP archive", "type": "string", "examples": ["/var/opt/server.zip"], + "minLength": 1, }, "zip_root": { "title": "Server root directory", "description": "The server root in the ZIP archive", "type": "string", "examples": ["/", "/paper-server/", "server-1"], + "minLength": 1, }, "jarfile": { "title": "JAR file", "description": "The JAR relative to the configured root", "type": "string", "examples": ["paper.jar", "jars/vanilla-1.12.jar"], + "minLength": 1, }, "mem_min": { "title": "Minimum JVM memory (in GiBs)", @@ -356,12 +366,14 @@ new_server_schema = { "description": "Absolute path to the old server", "type": "string", "examples": ["/var/opt/server"], + "minLength": 1, }, "command": { "title": "Command", "type": "string", "default": "echo foo bar baz", "examples": ["LD_LIBRARY_PATH=. ./bedrock_server"], + "minLength": 1, }, }, }, @@ -375,18 +387,21 @@ new_server_schema = { "description": "Absolute path to the ZIP archive", "type": "string", "examples": ["/var/opt/server.zip"], + "minLength": 1, }, "zip_root": { "title": "Server root directory", "description": "The server root in the ZIP archive", "type": "string", "examples": ["/", "/paper-server/", "server-1"], + "minLength": 1, }, "command": { "title": "Command", "type": "string", "default": "echo foo bar baz", "examples": ["LD_LIBRARY_PATH=. ./bedrock_server"], + "minLength": 1, }, }, }, @@ -474,6 +489,7 @@ new_server_schema = { "type": "string", "default": "echo foo bar baz", "examples": ["caddy start"], + "minLength": 1, } }, }, @@ -487,12 +503,14 @@ new_server_schema = { "description": "Absolute path to the old server", "type": "string", "examples": ["/var/opt/server"], + "minLength": 1, }, "command": { "title": "Command", "type": "string", "default": "echo foo bar baz", "examples": ["caddy start"], + "minLength": 1, }, }, }, @@ -506,18 +524,21 @@ new_server_schema = { "description": "Absolute path to the ZIP archive", "type": "string", "examples": ["/var/opt/server.zip"], + "minLength": 1, }, "zip_root": { "title": "Server root directory", "description": "The server root in the ZIP archive", "type": "string", "examples": ["/", "/paper-server/", "server-1"], + "minLength": 1, }, "command": { "title": "Command", "type": "string", "default": "echo foo bar baz", "examples": ["caddy start"], + "minLength": 1, }, }, }, diff --git a/app/classes/web/routes/api/servers/server/index.py b/app/classes/web/routes/api/servers/server/index.py index e0a5ea8a..658d1c43 100644 --- a/app/classes/web/routes/api/servers/server/index.py +++ b/app/classes/web/routes/api/servers/server/index.py @@ -11,21 +11,21 @@ logger = logging.getLogger(__name__) server_patch_schema = { "type": "object", "properties": { - "server_name": {"type": "string"}, - "path": {"type": "string"}, + "server_name": {"type": "string", "minLength": 1}, + "path": {"type": "string", "minLength": 1}, "backup_path": {"type": "string"}, "executable": {"type": "string"}, - "log_path": {"type": "string"}, - "execution_command": {"type": "string"}, + "log_path": {"type": "string", "minLength": 1}, + "execution_command": {"type": "string", "minLength": 1}, "auto_start": {"type": "boolean"}, "auto_start_delay": {"type": "integer"}, "crash_detection": {"type": "boolean"}, "stop_command": {"type": "string"}, - "executable_update_url": {"type": "string"}, - "server_ip": {"type": "string"}, + "executable_update_url": {"type": "string", "minLength": 1}, + "server_ip": {"type": "string", "minLength": 1}, "server_port": {"type": "integer"}, "logs_delete_after": {"type": "integer"}, - "type": {"type": "string"}, + "type": {"type": "string", "minLength": 1}, }, "anyOf": [ # Require at least one property