diff --git a/.gitignore b/.gitignore index bf21675b..0c17d6c7 100644 --- a/.gitignore +++ b/.gitignore @@ -18,8 +18,10 @@ env.bak/ venv.bak/ .idea/ -servers/ -backups/ +/servers/ +/backups/ +/docker/servers/ +/docker/backups/ session.lock .header default.json diff --git a/app/classes/controllers/crafty_perms_controller.py b/app/classes/controllers/crafty_perms_controller.py index fa16ea65..9c79c33a 100644 --- a/app/classes/controllers/crafty_perms_controller.py +++ b/app/classes/controllers/crafty_perms_controller.py @@ -62,6 +62,14 @@ class CraftyPermsController: @staticmethod def add_server_creation(user_id): + """Increase the "Server Creation" counter for this user + + Args: + user_id (int): The modifiable user's ID + + Returns: + int: The new count of servers created by this user + """ return PermissionsCrafty.add_server_creation(user_id) @staticmethod diff --git a/app/classes/controllers/roles_controller.py b/app/classes/controllers/roles_controller.py index 266bb658..49f25d88 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__) @@ -16,6 +17,10 @@ class RolesController: def get_all_roles(): return HelperRoles.get_all_roles() + @staticmethod + def get_all_role_ids(): + return HelperRoles.get_all_role_ids() + @staticmethod def get_roleid_by_name(role_name): return HelperRoles.get_roleid_by_name(role_name) @@ -36,8 +41,12 @@ class RolesController: if key == "role_id": continue elif key == "servers": - added_servers = role_data["servers"].difference(base_data["servers"]) - removed_servers = base_data["servers"].difference(role_data["servers"]) + added_servers = set(role_data["servers"]).difference( + set(base_data["servers"]) + ) + removed_servers = set(base_data["servers"]).difference( + set(role_data["servers"]) + ) elif base_data[key] != role_data[key]: up_data[key] = role_data[key] up_data["last_update"] = Helpers.get_time_as_string() @@ -58,6 +67,95 @@ 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) + same_servers = server_ids.intersection(set(base_data["servers"])) + 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) + for server_id in same_servers: + PermissionsServers.update_role_permission( + role_id, server_id, server_permissions_map[server_id] + ) + 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"]) @@ -73,12 +171,8 @@ class RolesController: role = HelperRoles.get_role(role_id) if role: - servers_query = PermissionsServers.get_servers_from_role(role_id) - # TODO: this query needs to be narrower - servers = set() - for s in servers_query: - servers.add(s.server_id.server_id) - role["servers"] = servers + server_ids = PermissionsServers.get_server_ids_from_role(role_id) + role["servers"] = list(server_ids) # logger.debug("role: ({}) {}".format(role_id, role)) return role else: diff --git a/app/classes/controllers/servers_controller.py b/app/classes/controllers/servers_controller.py index ccbe975f..63f75e2d 100644 --- a/app/classes/controllers/servers_controller.py +++ b/app/classes/controllers/servers_controller.py @@ -1,6 +1,7 @@ import os import logging import json +import typing as t from app.classes.controllers.roles_controller import RolesController from app.classes.models.servers import HelperServers @@ -34,9 +35,31 @@ class ServersController: server_log_file: str, server_stop: str, server_type: str, - server_port=25565, - ): - return self.servers_helper.create_server( + server_port: int = 25565, + server_host: str = "127.0.0.1", + ) -> int: + """Create a server in the database + + Args: + name: The name of the server + server_uuid: This is the UUID of the server + server_dir: The directory where the server is located + backup_path: The path to the backup folder + server_command: The command to start the server + server_file: The name of the server file + server_log_file: The path to the server log file + server_stop: This is the command to stop the server + server_type: This is the type of server you're creating. + server_port: The port the server will be monitored on, defaults to 25565 + server_host: The host the server will be monitored on, defaults to 127.0.0.1 + + Returns: + int: The new server's id + + Raises: + PeeweeException: If the server already exists + """ + return HelperServers.create_server( name, server_uuid, server_dir, @@ -47,6 +70,7 @@ class ServersController: server_stop, server_type, server_port, + server_host, ) @staticmethod @@ -92,7 +116,7 @@ class ServersController: @staticmethod def get_authorized_servers(user_id): - server_data = [] + server_data: t.List[t.Dict[str, t.Any]] = [] user_roles = HelperUsers.user_role_query(user_id) for user in user_roles: role_servers = PermissionsServers.get_role_servers_from_role_id( @@ -103,6 +127,20 @@ class ServersController: return server_data + @staticmethod + def get_authorized_users(server_id: str): + user_ids: t.Set[int] = set() + roles_list = PermissionsServers.get_roles_from_server(server_id) + for role in roles_list: + role_users = HelperUsers.get_users_from_role(role.role_id) + for user_role in role_users: + user_ids.add(user_role.user_id) + + for user_id in HelperUsers.get_super_user_list(): + user_ids.add(user_id) + + return user_ids + @staticmethod def get_all_servers_stats(): return HelperServerStats.get_all_servers_stats() @@ -111,7 +149,7 @@ class ServersController: def get_authorized_servers_stats_api_key(api_key: ApiKeys): server_data = [] authorized_servers = ServersController.get_authorized_servers( - api_key.user.user_id + api_key.user.user_id # TODO: API key authorized servers? ) for server in authorized_servers: diff --git a/app/classes/controllers/users_controller.py b/app/classes/controllers/users_controller.py index 6d13cf46..569de4a6 100644 --- a/app/classes/controllers/users_controller.py +++ b/app/classes/controllers/users_controller.py @@ -1,5 +1,5 @@ import logging -from typing import Optional +import typing as t from app.classes.models.users import HelperUsers from app.classes.models.crafty_permissions import ( @@ -16,6 +16,74 @@ class UsersController: self.users_helper = users_helper self.authentication = authentication + _permissions_props = { + "name": { + "type": "string", + "enum": [ + permission.name + for permission in PermissionsCrafty.get_permissions_list() + ], + }, + "quantity": {"type": "number", "minimum": 0}, + "enabled": {"type": "boolean"}, + } + self.user_jsonschema_props: t.Final = { + "username": { + "type": "string", + "maxLength": 20, + "minLength": 4, + "pattern": "^[a-z0-9_]+$", + "examples": ["admin"], + "title": "Username", + }, + "password": { + "type": "string", + "maxLength": 20, + "minLength": 4, + "examples": ["crafty"], + "title": "Password", + }, + "email": { + "type": "string", + "format": "email", + "examples": ["default@example.com"], + "title": "E-Mail", + }, + "enabled": { + "type": "boolean", + "examples": [True], + "title": "Enabled", + }, + "lang": { + "type": "string", + "maxLength": 10, + "minLength": 2, + "examples": ["en"], + "title": "Language", + }, + "superuser": { + "type": "boolean", + "examples": [False], + "title": "Superuser", + }, + "permissions": { + "type": "array", + "items": { + "type": "object", + "properties": _permissions_props, + "required": ["name", "quantity", "enabled"], + }, + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + }, + }, + "hints": {"type": "boolean"}, + } + # ********************************************************************************** # Users Methods # ********************************************************************************** @@ -23,6 +91,10 @@ class UsersController: def get_all_users(): return HelperUsers.get_all_users() + @staticmethod + def get_all_user_ids() -> t.List[int]: + return HelperUsers.get_all_user_ids() + @staticmethod def get_id_by_name(username): return HelperUsers.get_user_id_by_name(username) @@ -64,8 +136,12 @@ class UsersController: if key == "user_id": continue elif key == "roles": - added_roles = user_data["roles"].difference(base_data["roles"]) - removed_roles = base_data["roles"].difference(user_data["roles"]) + added_roles = set(user_data["roles"]).difference( + set(base_data["roles"]) + ) + removed_roles = set(base_data["roles"]).difference( + set(user_data["roles"]) + ) elif key == "password": if user_data["password"] is not None and user_data["password"] != "": up_data["password"] = self.helper.encode_pass(user_data["password"]) @@ -82,16 +158,16 @@ class UsersController: permissions_mask = user_crafty_data.get("permissions_mask", "000") if "server_quantity" in user_crafty_data: - limit_server_creation = user_crafty_data["server_quantity"][ - EnumPermissionsCrafty.SERVER_CREATION.name - ] + limit_server_creation = user_crafty_data["server_quantity"].get( + EnumPermissionsCrafty.SERVER_CREATION.name, 0 + ) - limit_user_creation = user_crafty_data["server_quantity"][ - EnumPermissionsCrafty.USER_CONFIG.name - ] - limit_role_creation = user_crafty_data["server_quantity"][ - EnumPermissionsCrafty.ROLES_CONFIG.name - ] + limit_user_creation = user_crafty_data["server_quantity"].get( + EnumPermissionsCrafty.USER_CONFIG.name, 0 + ) + limit_role_creation = user_crafty_data["server_quantity"].get( + EnumPermissionsCrafty.ROLES_CONFIG.name, 0 + ) else: limit_server_creation = 0 limit_user_creation = 0 @@ -109,6 +185,15 @@ class UsersController: self.users_helper.update_user(user_id, up_data) + def raw_update_user(self, user_id: int, up_data: t.Optional[t.Dict[str, t.Any]]): + """Directly passes the data to the model helper. + + Args: + user_id (int): The id of the user to update. + up_data (t.Optional[t.Dict[str, t.Any]]): Update data. + """ + self.users_helper.update_user(user_id, up_data) + def add_user( self, username, @@ -161,7 +246,7 @@ class UsersController: return token_data["user_id"] def get_user_by_api_token(self, token: str): - _, _, user = self.authentication.check(token) + _, _, user = self.authentication.check_err(token) return user def get_api_key_by_token(self, token: str): @@ -207,8 +292,8 @@ class UsersController: name: str, user_id: str, superuser: bool = False, - server_permissions_mask: Optional[str] = None, - crafty_permissions_mask: Optional[str] = None, + server_permissions_mask: t.Optional[str] = None, + crafty_permissions_mask: t.Optional[str] = None, ): return self.users_helper.add_user_api_key( name, user_id, superuser, server_permissions_mask, crafty_permissions_mask diff --git a/app/classes/minecraft/serverjars.py b/app/classes/minecraft/serverjars.py index 380bfde8..b1fc9580 100644 --- a/app/classes/minecraft/serverjars.py +++ b/app/classes/minecraft/serverjars.py @@ -22,17 +22,10 @@ class ServerJars: try: response = requests.get(full_url, timeout=2) - - if response.status_code not in [200, 201]: - return {} - except Exception as e: - logger.error(f"Unable to connect to serverjar.com api due to error: {e}") - return {} - - try: + response.raise_for_status() api_data = json.loads(response.content) except Exception as e: - logger.error(f"Unable to parse serverjar.com api result due to error: {e}") + logger.error(f"Unable to load {full_url} api due to error: {e}") return {} api_result = api_data.get("status") diff --git a/app/classes/models/crafty_permissions.py b/app/classes/models/crafty_permissions.py index b58b3c7d..936d88bd 100644 --- a/app/classes/models/crafty_permissions.py +++ b/app/classes/models/crafty_permissions.py @@ -1,4 +1,5 @@ import logging +import typing from enum import Enum from peewee import ( ForeignKeyField, @@ -45,21 +46,24 @@ class PermissionsCrafty: # ********************************************************************************** @staticmethod def get_permissions_list(): - permissions_list = [] + permissions_list: typing.List[EnumPermissionsCrafty] = [] for member in EnumPermissionsCrafty.__members__.items(): permissions_list.append(member[1]) return permissions_list @staticmethod def get_permissions(permissions_mask): - permissions_list = [] + permissions_list: typing.List[EnumPermissionsCrafty] = [] for member in EnumPermissionsCrafty.__members__.items(): if PermissionsCrafty.has_permission(permissions_mask, member[1]): permissions_list.append(member[1]) return permissions_list @staticmethod - def has_permission(permission_mask, permission_tested: EnumPermissionsCrafty): + def has_permission( + permission_mask: typing.Mapping[int, str], + permission_tested: EnumPermissionsCrafty, + ): result = False if permission_mask[permission_tested.value] == "1": result = True @@ -188,6 +192,14 @@ class PermissionsCrafty: @staticmethod def add_server_creation(user_id): + """Increase the "Server Creation" counter for this user + + Args: + user_id (int): The modifiable user's ID + + Returns: + int: The new count of servers created by this user + """ user_crafty = PermissionsCrafty.get_user_crafty(user_id) user_crafty.created_server += 1 UserCrafty.save(user_crafty) diff --git a/app/classes/models/management.py b/app/classes/models/management.py index 15bbec76..1503844e 100644 --- a/app/classes/models/management.py +++ b/app/classes/models/management.py @@ -180,7 +180,12 @@ class HelpersManagement: server_users = PermissionsServers.get_server_user_list(server_id) for user in server_users: - self.helper.websocket_helper.broadcast_user(user, "notification", audit_msg) + try: + self.helper.websocket_helper.broadcast_user( + user, "notification", audit_msg + ) + except Exception as e: + logger.error(f"Error broadcasting to user {user} - {e}") AuditLog.insert( { @@ -191,7 +196,7 @@ class HelpersManagement: AuditLog.source_ip: source_ip, } ).execute() - # deletes records when they're more than 100 + # deletes records when there's more than 300 ordered = AuditLog.select().order_by(+AuditLog.created) for item in ordered: if not self.helper.get_setting("max_audit_entries"): @@ -213,7 +218,7 @@ class HelpersManagement: AuditLog.source_ip: source_ip, } ).execute() - # deletes records when they're more than 100 + # deletes records when there's more than 300 ordered = AuditLog.select().order_by(+AuditLog.created) for item in ordered: # configurable through app/config/config.json @@ -400,7 +405,7 @@ class HelpersManagement: return dir_list def add_excluded_backup_dir(self, server_id: int, dir_to_add: str): - dir_list = self.get_excluded_backup_dirs() + dir_list = self.get_excluded_backup_dirs(server_id) if dir_to_add not in dir_list: dir_list.append(dir_to_add) excluded_dirs = ",".join(dir_list) @@ -412,7 +417,7 @@ class HelpersManagement: ) def del_excluded_backup_dir(self, server_id: int, dir_to_del: str): - dir_list = self.get_excluded_backup_dirs() + dir_list = self.get_excluded_backup_dirs(server_id) if dir_to_del in dir_list: dir_list.remove(dir_to_del) excluded_dirs = ",".join(dir_list) diff --git a/app/classes/models/roles.py b/app/classes/models/roles.py index 373fa11d..e5afcd31 100644 --- a/app/classes/models/roles.py +++ b/app/classes/models/roles.py @@ -1,5 +1,6 @@ import logging import datetime +import typing as t from peewee import ( CharField, DoesNotExist, @@ -35,8 +36,11 @@ class HelperRoles: @staticmethod def get_all_roles(): - query = Roles.select() - return query + return Roles.select() + + @staticmethod + def get_all_role_ids() -> t.List[int]: + return [role.role_id for role in Roles.select(Roles.role_id).execute()] @staticmethod def get_roleid_by_name(role_name): @@ -49,6 +53,24 @@ class HelperRoles: def get_role(role_id): return model_to_dict(Roles.get(Roles.role_id == role_id)) + @staticmethod + def get_role_columns( + role_id: t.Union[str, int], column_names: t.List[str] + ) -> t.List[t.Any]: + columns = [getattr(Roles, column) for column in column_names] + return model_to_dict( + Roles.select(*columns).where(Roles.role_id == role_id).get(), + only=columns, + ) + + @staticmethod + def get_role_column(role_id: t.Union[str, int], column_name: str) -> t.Any: + column = getattr(Roles, column_name) + return model_to_dict( + Roles.select(column).where(Roles.role_id == role_id).get(), + only=[column], + )[column_name] + @staticmethod def add_role(role_name): role_id = Roles.insert( @@ -64,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 c4ed6c48..02b3cd91 100644 --- a/app/classes/models/server_permissions.py +++ b/app/classes/models/server_permissions.py @@ -1,6 +1,6 @@ +import logging import typing as t from enum import Enum -import logging from peewee import ( ForeignKeyField, CharField, @@ -52,14 +52,14 @@ class PermissionsServers: @staticmethod def get_permissions_list(): - permissions_list = [] + permissions_list: t.List[EnumPermissionsServer] = [] for member in EnumPermissionsServer.__members__.items(): permissions_list.append(member[1]) return permissions_list @staticmethod def get_permissions(permissions_mask): - permissions_list = [] + permissions_list: t.List[EnumPermissionsServer] = [] for member in EnumPermissionsServer.__members__.items(): if PermissionsServers.has_permission(permissions_mask, member[1]): permissions_list.append(member[1]) @@ -96,17 +96,29 @@ class PermissionsServers: # Role_Servers Methods # ********************************************************************************** @staticmethod - def get_role_servers_from_role_id(roleid): + def get_role_servers_from_role_id(roleid: t.Union[str, int]): return RoleServers.select().where(RoleServers.role_id == roleid) @staticmethod - def get_servers_from_role(role_id): + def get_servers_from_role(role_id: t.Union[str, int]): return ( RoleServers.select() .join(Servers, JOIN.INNER) .where(RoleServers.role_id == role_id) ) + @staticmethod + def get_server_ids_from_role(role_id: t.Union[str, int]) -> t.List[int]: + # FIXME: somehow retrieve only the server ids, not the whole servers + return [ + role_servers.server_id.server_id + for role_servers in ( + RoleServers.select(RoleServers.server_id).where( + RoleServers.role_id == role_id + ) + ) + ] + @staticmethod def get_roles_from_server(server_id): return ( @@ -179,9 +191,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 fb0a1658..5b7617b1 100644 --- a/app/classes/models/servers.py +++ b/app/classes/models/servers.py @@ -1,5 +1,6 @@ import logging import datetime +import typing as t from peewee import ( CharField, AutoField, @@ -7,6 +8,7 @@ from peewee import ( BooleanField, IntegerField, ) +from playhouse.shortcuts import model_to_dict from app.classes.shared.main_models import DatabaseShortcuts from app.classes.models.base_model import BaseModel @@ -61,8 +63,30 @@ class HelperServers: server_log_file: str, server_stop: str, server_type: str, - server_port=25565, - ): + server_port: int = 25565, + server_host: str = "127.0.0.1", + ) -> int: + """Create a server in the database + + Args: + name: The name of the server + server_uuid: This is the UUID of the server + server_dir: The directory where the server is located + backup_path: The path to the backup folder + server_command: The command to start the server + server_file: The name of the server file + server_log_file: The path to the server log file + server_stop: This is the command to stop the server + server_type: This is the type of server you're creating. + server_port: The port the server will be monitored on, defaults to 25565 + server_host: The host the server will be monitored on, defaults to 127.0.0.1 + + Returns: + int: The new server's id + + Raises: + PeeweeException: If the server already exists + """ return Servers.insert( { Servers.server_name: name, @@ -75,6 +99,7 @@ class HelperServers: Servers.crash_detection: False, Servers.log_path: server_log_file, Servers.server_port: server_port, + Servers.server_ip: server_host, Servers.stop_command: server_stop, Servers.backup_path: backup_path, Servers.type: server_type, @@ -106,6 +131,24 @@ class HelperServers: except IndexError: return {} + @staticmethod + def get_server_columns( + server_id: t.Union[str, int], column_names: t.List[str] + ) -> t.List[t.Any]: + columns = [getattr(Servers, column) for column in column_names] + return model_to_dict( + Servers.select(*columns).where(Servers.server_id == server_id).get(), + only=columns, + ) + + @staticmethod + def get_server_column(server_id: t.Union[str, int], column_name: str) -> t.Any: + column = getattr(Servers, column_name) + return model_to_dict( + Servers.select(column).where(Servers.server_id == server_id).get(), + only=[column], + )[column_name] + # ********************************************************************************** # Servers Methods # ********************************************************************************** @@ -114,6 +157,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_server_friendly_name(server_id): server_data = HelperServers.get_server_data_by_id(server_id) diff --git a/app/classes/models/users.py b/app/classes/models/users.py index ddfdbd8f..5370d430 100644 --- a/app/classes/models/users.py +++ b/app/classes/models/users.py @@ -1,6 +1,6 @@ import logging import datetime -from typing import Optional, Union +import typing as t from peewee import ( ForeignKeyField, @@ -45,6 +45,15 @@ class Users(BaseModel): table_name = "users" +PUBLIC_USER_ATTRS: t.Final = [ + "user_id", + "created", + "username", + "enabled", + "superuser", + "lang", # maybe remove? +] + # ********************************************************************************** # API Keys Class # ********************************************************************************** @@ -90,6 +99,15 @@ class HelperUsers: query = Users.select().where(Users.username != "system") return query + @staticmethod + def get_all_user_ids() -> t.List[int]: + return [ + user.user_id + for user in Users.select(Users.user_id) + .where(Users.username != "system") + .execute() + ] + @staticmethod def get_user_lang_by_id(user_id): return Users.get(Users.user_id == user_id).lang @@ -134,6 +152,24 @@ class HelperUsers: # logger.debug("user: ({}) {}".format(user_id, {})) return {} + @staticmethod + def get_user_columns( + user_id: t.Union[str, int], column_names: t.List[str] + ) -> t.List[t.Any]: + columns = [getattr(Users, column) for column in column_names] + return model_to_dict( + Users.select(*columns).where(Users.user_id == user_id).get(), + only=columns, + ) + + @staticmethod + def get_user_column(user_id: t.Union[str, int], column_name: str) -> t.Any: + column = getattr(Users, column_name) + return model_to_dict( + Users.select(column).where(Users.user_id == user_id).get(), + only=[column], + )[column_name] + @staticmethod def check_system_user(user_id): try: @@ -153,7 +189,7 @@ class HelperUsers: self, username: str, password: str = None, - email: Optional[str] = None, + email: t.Optional[str] = None, enabled: bool = True, superuser: bool = False, ) -> str: @@ -177,7 +213,7 @@ class HelperUsers: def add_rawpass_user( username: str, password: str = None, - email: Optional[str] = None, + email: t.Optional[str] = None, enabled: bool = True, superuser: bool = False, ) -> str: @@ -212,7 +248,7 @@ class HelperUsers: @staticmethod def get_super_user_list(): - final_users = [] + final_users: t.List[int] = [] super_users = Users.select().where( Users.superuser == True # pylint: disable=singleton-comparison ) @@ -224,8 +260,7 @@ class HelperUsers: def remove_user(self, user_id): with self.database.atomic(): UserRoles.delete().where(UserRoles.user_id == user_id).execute() - user = Users.get(Users.user_id == user_id) - return user.delete_instance() + return Users.delete().where(Users.user_id == user_id).execute() @staticmethod def set_support_path(user_id, support_path): @@ -271,11 +306,10 @@ class HelperUsers: @staticmethod def get_user_roles_names(user_id): - roles_list = [] - roles = UserRoles.select().where(UserRoles.user_id == user_id) - for r in roles: - roles_list.append(HelperRoles.get_role(r.role_id)["role_name"]) - return roles_list + roles = UserRoles.select(UserRoles.role_id).where(UserRoles.user_id == user_id) + return [ + HelperRoles.get_role_column(role.role_id, "role_name") for role in roles + ] @staticmethod def add_role_to_user(user_id, role_id): @@ -284,7 +318,7 @@ class HelperUsers: ).execute() @staticmethod - def add_user_roles(user: Union[dict, Users]): + def add_user_roles(user: t.Union[dict, Users]): if isinstance(user, dict): user_id = user["user_id"] else: @@ -329,6 +363,10 @@ class HelperUsers: def remove_roles_from_role_id(role_id): UserRoles.delete().where(UserRoles.role_id == role_id).execute() + @staticmethod + def get_users_from_role(role_id): + UserRoles.select().where(UserRoles.role_id == role_id).execute() + # ********************************************************************************** # ApiKeys Methods # ********************************************************************************** @@ -346,8 +384,8 @@ class HelperUsers: name: str, user_id: str, superuser: bool = False, - server_permissions_mask: Optional[str] = None, - crafty_permissions_mask: Optional[str] = None, + server_permissions_mask: t.Optional[str] = None, + crafty_permissions_mask: t.Optional[str] = None, ): return ApiKeys.insert( { diff --git a/app/classes/shared/authentication.py b/app/classes/shared/authentication.py index fbe2fb58..3596ae9e 100644 --- a/app/classes/shared/authentication.py +++ b/app/classes/shared/authentication.py @@ -34,7 +34,7 @@ class Authentication: def check_no_iat(self, token) -> Optional[Dict[str, Any]]: try: - return jwt.decode(token, self.secret, algorithms=["HS256"]) + return jwt.decode(str(token), self.secret, algorithms=["HS256"]) except PyJWTError as error: logger.debug("Error while checking JWT token: ", exc_info=error) return None @@ -44,7 +44,7 @@ class Authentication: token, ) -> Optional[Tuple[Optional[ApiKeys], Dict[str, Any], Dict[str, Any]]]: try: - data = jwt.decode(token, self.secret, algorithms=["HS256"]) + data = jwt.decode(str(token), self.secret, algorithms=["HS256"]) except PyJWTError as error: logger.debug("Error while checking JWT token: ", exc_info=error) return None @@ -65,5 +65,17 @@ class Authentication: else: return None + def check_err( + self, + token, + ) -> Tuple[Optional[ApiKeys], Dict[str, Any], Dict[str, Any]]: + # Without this function there would be runtime exceptions like the following: + # "None" object is not iterable + + output = self.check(token) + if output is None: + raise Exception("Invalid token") + return output + def check_bool(self, token) -> bool: return self.check(token) is not None diff --git a/app/classes/shared/helpers.py b/app/classes/shared/helpers.py index ed97507d..778bfbfb 100644 --- a/app/classes/shared/helpers.py +++ b/app/classes/shared/helpers.py @@ -72,7 +72,7 @@ class Helpers: installer.do_install() @staticmethod - def float_to_string(gbs: int): + def float_to_string(gbs: float): s = str(float(gbs) * 1000).rstrip("0").rstrip(".") return s @@ -232,7 +232,7 @@ class Helpers: return default_return with open(self.settings_file, "w", encoding="utf-8") as f: - json.dump(data, f, indent=1) + json.dump(data, f, indent=2) except Exception as e: logger.critical( @@ -270,18 +270,17 @@ class Helpers: @staticmethod def get_announcements(): - response = requests.get("https://craftycontrol.com/notify.json", timeout=2) data = ( '[{"id":"1","date":"Unknown",' '"title":"Error getting Announcements",' '"desc":"Error getting Announcements","link":""}]' ) - if response.status_code in [200, 201]: - try: - data = json.loads(response.content) - except Exception as e: - logger.error(f"Failed to load json content with error: {e}") + try: + response = requests.get("https://craftycontrol.com/notify.json", timeout=2) + data = json.loads(response.content) + except Exception as e: + logger.error(f"Failed to fetch notifications with error: {e}") return data @@ -1001,10 +1000,11 @@ class Helpers: return text @staticmethod - def get_lang_page(text): - lang = text.split("_")[0] - region = text.split("_")[1] + def get_lang_page(text) -> str: + splitted = text.split("_") + if len(splitted) != 2: + return "en" + lang, region = splitted if region == "EN": return "en" - else: - return lang + "-" + region + return lang + "-" + region diff --git a/app/classes/shared/main_controller.py b/app/classes/shared/main_controller.py index 0c0fcc5f..6553dbc9 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 = [] @@ -337,6 +341,177 @@ class Controller: svr_obj = self.get_server_obj(server_id) svr_obj.stop_threaded_server() + def create_api_server(self, data: dict): + server_fs_uuid = Helpers.create_uuid() + new_server_path = os.path.join(self.helper.servers_dir, server_fs_uuid) + backup_path = os.path.join(self.helper.backup_path, server_fs_uuid) + + if Helpers.is_os_windows(): + new_server_path = Helpers.wtol_path(new_server_path) + backup_path = Helpers.wtol_path(backup_path) + new_server_path.replace(" ", "^ ") + backup_path.replace(" ", "^ ") + + Helpers.ensure_dir_exists(new_server_path) + Helpers.ensure_dir_exists(backup_path) + + def _copy_import_dir_files(existing_server_path): + existing_server_path = Helpers.get_os_understandable_path( + existing_server_path + ) + try: + FileHelpers.copy_dir(existing_server_path, new_server_path, True) + except shutil.Error as ex: + logger.error(f"Server import failed with error: {ex}") + + def _create_server_properties_if_needed(port, empty=False): + properties_file = os.path.join(new_server_path, "server.properties") + has_properties = os.path.exists(properties_file) + + if not has_properties: + logger.info( + f"No server.properties found on import." + f"Creating one with port selection of {port}" + ) + with open( + properties_file, + "w", + encoding="utf-8", + ) as file: + file.write( + "# generated by Crafty Controller" + + ("" if empty else f"\nserver-port={port}") + ) + + root_create_data = data[data["create_type"] + "_create_data"] + create_data = root_create_data[root_create_data["create_type"] + "_create_data"] + if data["create_type"] == "minecraft_java": + if root_create_data["create_type"] == "download_jar": + server_file = f"{create_data['type']}-{create_data['version']}.jar" + full_jar_path = os.path.join(new_server_path, server_file) + + # Create an EULA file + with open( + os.path.join(new_server_path, "eula.txt"), "w", encoding="utf-8" + ) as file: + file.write( + "eula=" + ("true" if create_data["agree_to_eula"] else "false") + ) + elif root_create_data["create_type"] == "import_server": + _copy_import_dir_files(create_data["existing_server_path"]) + full_jar_path = os.path.join(new_server_path, create_data["jarfile"]) + elif root_create_data["create_type"] == "import_zip": + # TODO: Copy files from the zip file to the new server directory + full_jar_path = os.path.join(new_server_path, create_data["jarfile"]) + raise Exception("Not yet implemented") + _create_server_properties_if_needed(create_data["server_properties_port"]) + + min_mem = create_data["mem_min"] + max_mem = create_data["mem_max"] + + def _gibs_to_mibs(gibs: float) -> str: + return str(int(gibs * 1024)) + + def _wrap_jar_if_windows(): + return ( + f'"{full_jar_path}"' if Helpers.is_os_windows() else full_jar_path + ) + + server_command = ( + f"java -Xms{_gibs_to_mibs(min_mem)}M " + f"-Xmx{_gibs_to_mibs(max_mem)}M " + f"-jar {_wrap_jar_if_windows()} nogui" + ) + elif data["create_type"] == "minecraft_bedrock": + if root_create_data["create_type"] == "import_server": + existing_server_path = Helpers.get_os_understandable_path( + create_data["existing_server_path"] + ) + try: + FileHelpers.copy_dir(existing_server_path, new_server_path, True) + except shutil.Error as ex: + logger.error(f"Server import failed with error: {ex}") + elif root_create_data["create_type"] == "import_zip": + # TODO: Copy files from the zip file to the new server directory + raise Exception("Not yet implemented") + + _create_server_properties_if_needed(0, True) + + server_command = create_data["command"] + server_file = "" + elif data["create_type"] == "custom": + # TODO: working_directory, executable_update + if root_create_data["create_type"] == "raw_exec": + pass + elif root_create_data["create_type"] == "import_server": + existing_server_path = Helpers.get_os_understandable_path( + create_data["existing_server_path"] + ) + try: + FileHelpers.copy_dir(existing_server_path, new_server_path, True) + except shutil.Error as ex: + logger.error(f"Server import failed with error: {ex}") + elif root_create_data["create_type"] == "import_zip": + # TODO: Copy files from the zip file to the new server directory + raise Exception("Not yet implemented") + + _create_server_properties_if_needed(0, True) + + server_command = create_data["command"] + server_file = root_create_data["executable_update"].get("file", "") + + stop_command = data.get("stop_command", "") + if stop_command == "": + # TODO: different default stop commands for server creation types + stop_command = "stop" + + log_location = data.get("log_location", "") + if log_location == "": + # TODO: different default log locations for server creation types + log_location = "/logs/latest.log" + + if data["monitoring_type"] == "minecraft_java": + monitoring_port = data["minecraft_java_monitoring_data"]["port"] + monitoring_host = data["minecraft_java_monitoring_data"]["host"] + monitoring_type = "minecraft-java" + elif data["monitoring_type"] == "minecraft_bedrock": + monitoring_port = data["minecraft_bedrock_monitoring_data"]["port"] + monitoring_host = data["minecraft_bedrock_monitoring_data"]["host"] + monitoring_type = "minecraft-bedrock" + elif data["monitoring_type"] == "none": + # TODO: this needs to be NUKED.. + # There shouldn't be anything set if there are nothing to monitor + monitoring_port = 25565 + monitoring_host = "127.0.0.1" + monitoring_type = "minecraft-java" + + new_server_id = self.register_server( + name=data["name"], + server_uuid=server_fs_uuid, + server_dir=new_server_path, + backup_path=backup_path, + server_command=server_command, + server_file=server_file, + server_log_file=log_location, + server_stop=stop_command, + server_port=monitoring_port, + server_host=monitoring_host, + server_type=monitoring_type, + ) + + if ( + data["create_type"] == "minecraft_java" + and root_create_data["create_type"] == "download_jar" + ): + self.server_jars.download_jar( + create_data["type"], + create_data["version"], + full_jar_path, + new_server_id, + ) + + return new_server_id, server_fs_uuid + def create_jar_server( self, server: str, @@ -759,6 +934,7 @@ class Controller: server_stop: str, server_port: int, server_type: str, + server_host: str = "127.0.0.1", ): # put data in the db new_id = self.servers.create_server( @@ -772,6 +948,7 @@ class Controller: server_stop, server_type, server_port, + server_host, ) if not Helpers.check_file_exists( @@ -788,7 +965,6 @@ class Controller: "The server is managed by Crafty Controller.\n " "Leave this directory/files alone please" ) - file.close() except Exception as e: logger.error(f"Unable to create required server files due to :{e}") diff --git a/app/classes/shared/migration.py b/app/classes/shared/migration.py index 9007442c..c31542a2 100644 --- a/app/classes/shared/migration.py +++ b/app/classes/shared/migration.py @@ -81,7 +81,7 @@ class Migrator(object): database = database.obj self.database: SqliteDatabase = database self.table_dict: t.Dict[str, peewee.Model] = {} - self.operations: t.List[t.Union[Operation, callable]] = [] + self.operations: t.List[t.Union[Operation, t.Callable]] = [] self.migrator = SqliteMigrator(database) def run(self): diff --git a/app/classes/shared/tasks.py b/app/classes/shared/tasks.py index 93f53a25..3892d662 100644 --- a/app/classes/shared/tasks.py +++ b/app/classes/shared/tasks.py @@ -12,6 +12,7 @@ from apscheduler.triggers.cron import CronTrigger from app.classes.models.management import HelpersManagement from app.classes.models.users import HelperUsers from app.classes.shared.console import Console +from app.classes.shared.main_controller import Controller from app.classes.web.tornado_handler import Webserver logger = logging.getLogger("apscheduler") @@ -32,6 +33,8 @@ scheduler_intervals = { class TasksManager: + controller: Controller + def __init__(self, helper, controller): self.helper = helper self.controller = controller @@ -101,6 +104,17 @@ class TasksManager: elif command == "restart_server": svr.restart_threaded_server(user_id) + elif command == "kill_server": + try: + svr.kill() + time.sleep(5) + svr.cleanup_server_object() + svr.record_server_stats() + except Exception as e: + logger.error( + f"Could not find PID for requested termsig. Full error: {e}" + ) + elif command == "backup_server": svr.backup_server() diff --git a/app/classes/web/base_api_handler.py b/app/classes/web/base_api_handler.py new file mode 100644 index 00000000..24d7328d --- /dev/null +++ b/app/classes/web/base_api_handler.py @@ -0,0 +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: + 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/base_handler.py b/app/classes/web/base_handler.py index 499cbb87..c7aca58c 100644 --- a/app/classes/web/base_handler.py +++ b/app/classes/web/base_handler.py @@ -1,18 +1,50 @@ import logging -from typing import Union, List, Optional, Tuple, Dict, Any +import re +import typing as t +import orjson import bleach import tornado.web +from app.classes.models.crafty_permissions import EnumPermissionsCrafty from app.classes.models.users import ApiKeys +from app.classes.shared.helpers import Helpers +from app.classes.shared.main_controller import Controller +from app.classes.shared.translation import Translation logger = logging.getLogger(__name__) +bearer_pattern = re.compile(r"^Bearer ", flags=re.IGNORECASE) + class BaseHandler(tornado.web.RequestHandler): + def set_default_headers(self) -> None: + """ + Fix CORS + """ + self.set_header("Access-Control-Allow-Origin", "*") + self.set_header( + "Access-Control-Allow-Headers", + "Content-Type, x-requested-with, Authorization", + ) + self.set_header( + "Access-Control-Allow-Methods", "POST, GET, PUT, DELETE, OPTIONS" + ) + + def options(self, *_, **__): + """ + Fix CORS + """ + # no body + self.set_status(204) + self.finish() nobleach = {bool, type(None)} redactables = ("pass", "api") + helper: Helpers + controller: Controller + translator: Translation + # noinspection PyAttributeOutsideInit def initialize( self, helper=None, controller=None, tasks_manager=None, translator=None @@ -30,11 +62,25 @@ class BaseHandler(tornado.web.RequestHandler): ) return remote_ip - current_user: Optional[Tuple[Optional[ApiKeys], Dict[str, Any], Dict[str, Any]]] + current_user: t.Tuple[t.Optional[ApiKeys], t.Dict[str, t.Any], t.Dict[str, t.Any]] + """ + A variable that contains the current user's data. Please see + Please only use this with routes using the `@tornado.web.authenticated` decorator. + """ def get_current_user( self, - ) -> Optional[Tuple[Optional[ApiKeys], Dict[str, Any], Dict[str, Any]]]: + ) -> t.Optional[ + t.Tuple[t.Optional[ApiKeys], t.Dict[str, t.Any], t.Dict[str, t.Any]] + ]: + """ + Get the token's API key, the token's payload and user data. + + Returns: + t.Optional[ApiKeys]: The API key of the token. + t.Dict[str, t.Any]: The token's payload. + t.Dict[str, t.Any]: The user's data from the database. + """ return self.controller.authentication.check(self.get_cookie("token")) def autobleach(self, name, text): @@ -53,15 +99,15 @@ class BaseHandler(tornado.web.RequestHandler): def get_argument( self, name: str, - default: Union[ + default: t.Union[ None, str, tornado.web._ArgDefaultMarker ] = tornado.web._ARG_DEFAULT, strip: bool = True, - ) -> Optional[str]: + ) -> t.Optional[str]: arg = self._get_argument(name, default, self.request.arguments, strip) return self.autobleach(name, arg) - def get_arguments(self, name: str, strip: bool = True) -> List[str]: + def get_arguments(self, name: str, strip: bool = True) -> t.List[str]: if not isinstance(strip, bool): raise AssertionError args = self._get_arguments(name, self.request.arguments, strip) @@ -69,3 +115,127 @@ class BaseHandler(tornado.web.RequestHandler): for arg in args: args_ret += self.autobleach(name, arg) return args_ret + + def access_denied(self, user: t.Optional[str], reason: t.Optional[str]): + ip = self.get_remote_ip() + route = self.request.path + if user is not None: + user_data = f"User {user} from IP {ip}" + else: + user_data = f"An unknown user from IP {ip}" + if reason: + ending = f"to the API route {route} because {reason}" + else: + ending = f"to the API route {route}" + logger.info(f"{user_data} was denied access {ending}") + self.finish_json( + 403, + { + "status": "error", + "error": "ACCESS_DENIED", + "info": "You were denied access to the requested resource", + }, + ) + + def _auth_get_api_token(self) -> t.Optional[str]: + """Get an API token from the request + + The API token is searched in the following order: + 1. The `token` query parameter + 2. The `Authorization` header + 3. The `token` cookie + + Returns: + t.Optional[str]: The API token or None if no token was found. + """ + logger.debug("Searching for specified token") + api_token = self.get_query_argument("token", None) + if api_token is None and self.request.headers.get("Authorization"): + api_token = bearer_pattern.sub( + "", self.request.headers.get("Authorization") + ) + elif api_token is None: + api_token = self.get_cookie("token") + return api_token + + def authenticate_user( + self, + ) -> t.Optional[ + t.Tuple[ + t.List, + t.List[EnumPermissionsCrafty], + t.List[str], + bool, + t.Dict[str, t.Any], + ] + ]: + try: + api_key, _token_data, user = self.controller.authentication.check_err( + self._auth_get_api_token() + ) + + superuser = user["superuser"] + if api_key is not None: + superuser = superuser and api_key.superuser + + exec_user_role = set() + if superuser: + authorized_servers = self.controller.list_defined_servers() + exec_user_role.add("Super User") + exec_user_crafty_permissions = ( + self.controller.crafty_perms.list_defined_crafty_permissions() + ) + + else: + if api_key is not None: + exec_user_crafty_permissions = ( + self.controller.crafty_perms.get_api_key_permissions_list( + api_key + ) + ) + else: + exec_user_crafty_permissions = ( + self.controller.crafty_perms.get_crafty_permissions_list( + user["user_id"] + ) + ) + logger.debug(user["roles"]) + for r in user["roles"]: + role = self.controller.roles.get_role(r) + exec_user_role.add(role["role_name"]) + authorized_servers = self.controller.servers.get_authorized_servers( + user["user_id"] # TODO: API key authorized servers? + ) + + logger.debug("Checking results") + if user: + return ( + authorized_servers, + exec_user_crafty_permissions, + exec_user_role, + superuser, + user, + ) + else: + logging.debug("Auth unsuccessful") + self.access_denied(None, "the user provided an invalid token") + return None + except Exception as auth_exception: + logger.debug( + "An error occured while authenticating an API user:", + exc_info=auth_exception, + ) + self.finish_json( + 403, + { + "status": "error", + "error": "ACCESS_DENIED", + "info": "An error occured while authenticating the user", + }, + ) + return None + + def finish_json(self, status: int, data: t.Dict[str, t.Any]): + self.set_status(status) + self.set_header("Content-Type", "application/json") + self.finish(orjson.dumps(data)) # pylint: disable=no-member diff --git a/app/classes/web/panel_handler.py b/app/classes/web/panel_handler.py index 02fd872b..2144cdd4 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 @@ -31,16 +31,16 @@ 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 in self.controller.users.get_all_users(): - user_roles_list = self.controller.users.get_user_roles_names(user.user_id) + for user_id in self.controller.users.get_all_user_ids(): + user_roles_list = self.controller.users.get_user_roles_names(user_id) # user_servers = # self.controller.servers.get_authorized_servers(user.user_id) - user_roles[user.user_id] = user_roles_list + 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 = self.get_argument(f"server_{server['server_id']}_access", "0") @@ -60,7 +60,7 @@ class PanelHandler(BaseHandler): servers.add((server["server_id"], permission_mask)) 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 ( @@ -101,6 +101,16 @@ class PanelHandler(BaseHandler): ) return permissions_mask + def get_perms_server(self) -> str: + permissions_mask: str = "00000000" + for permission in self.controller.server_perms.list_defined_permissions(): + argument = self.get_argument(f"permission_{permission.name}", None) + if argument is not None: + permissions_mask = self.controller.server_perms.set_permission( + permissions_mask, permission, 1 if argument == "1" else 0 + ) + return permissions_mask + def get_user_role_memberships(self) -> set: roles = set() for role in self.controller.roles.get_all_roles(): @@ -260,7 +270,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(), @@ -302,6 +312,8 @@ class PanelHandler(BaseHandler): else None, "superuser": superuser, } + + # http://en.gravatar.com/site/implement/images/#rating if self.helper.get_setting("allow_nsfw_profile_pictures"): rating = "x" else: diff --git a/app/classes/web/routes/api/api_handlers.py b/app/classes/web/routes/api/api_handlers.py new file mode 100644 index 00000000..e9218830 --- /dev/null +++ b/app/classes/web/routes/api/api_handlers.py @@ -0,0 +1,157 @@ +from app.classes.web.routes.api.index_handler import ApiIndexHandler +from app.classes.web.routes.api.jsonschema import ( + ApiJsonSchemaHandler, + ApiJsonSchemaListHandler, +) +from app.classes.web.routes.api.not_found import ApiNotFoundHandler +from app.classes.web.routes.api.auth.invalidate_tokens import ( + ApiAuthInvalidateTokensHandler, +) +from app.classes.web.routes.api.auth.login import ApiAuthLoginHandler +from app.classes.web.routes.api.roles.index import ApiRolesIndexHandler +from app.classes.web.routes.api.roles.role.index import ApiRolesRoleIndexHandler +from app.classes.web.routes.api.roles.role.servers import ApiRolesRoleServersHandler +from app.classes.web.routes.api.roles.role.users import ApiRolesRoleUsersHandler +from app.classes.web.routes.api.servers.index import ApiServersIndexHandler +from app.classes.web.routes.api.servers.server.action import ( + ApiServersServerActionHandler, +) +from app.classes.web.routes.api.servers.server.index import ApiServersServerIndexHandler +from app.classes.web.routes.api.servers.server.logs import ApiServersServerLogsHandler +from app.classes.web.routes.api.servers.server.public import ( + ApiServersServerPublicHandler, +) +from app.classes.web.routes.api.servers.server.stats import ApiServersServerStatsHandler +from app.classes.web.routes.api.servers.server.users import ApiServersServerUsersHandler +from app.classes.web.routes.api.users.index import ApiUsersIndexHandler +from app.classes.web.routes.api.users.user.index import ApiUsersUserIndexHandler +from app.classes.web.routes.api.users.user.pfp import ApiUsersUserPfpHandler +from app.classes.web.routes.api.users.user.public import ApiUsersUserPublicHandler + + +def api_handlers(handler_args): + return [ + # Auth routes + ( + r"/api/v2/auth/login/?", + ApiAuthLoginHandler, + handler_args, + ), + ( + r"/api/v2/auth/invalidate_tokens/?", + ApiAuthInvalidateTokensHandler, + handler_args, + ), + # User routes + ( + r"/api/v2/users/?", + ApiUsersIndexHandler, + handler_args, + ), + ( + r"/api/v2/users/([0-9]+)/?", + ApiUsersUserIndexHandler, + handler_args, + ), + ( + r"/api/v2/users/(@me)/?", + ApiUsersUserIndexHandler, + handler_args, + ), + ( + r"/api/v2/users/([0-9]+)/pfp/?", + ApiUsersUserPfpHandler, + handler_args, + ), + ( + r"/api/v2/users/(@me)/pfp/?", + ApiUsersUserPfpHandler, + handler_args, + ), + ( + r"/api/v2/users/([0-9]+)/public/?", + ApiUsersUserPublicHandler, + handler_args, + ), + ( + r"/api/v2/users/(@me)/public/?", + ApiUsersUserPublicHandler, + handler_args, + ), + # Server routes + ( + r"/api/v2/servers/?", + ApiServersIndexHandler, + handler_args, + ), + ( + r"/api/v2/servers/([0-9]+)/?", + ApiServersServerIndexHandler, + handler_args, + ), + ( + r"/api/v2/servers/([0-9]+)/stats/?", + ApiServersServerStatsHandler, + handler_args, + ), + ( + r"/api/v2/servers/([0-9]+)/action/([a-z_]+)/?", + ApiServersServerActionHandler, + handler_args, + ), + ( + r"/api/v2/servers/([0-9]+)/logs/?", + ApiServersServerLogsHandler, + handler_args, + ), + ( + r"/api/v2/servers/([0-9]+)/users/?", + ApiServersServerUsersHandler, + handler_args, + ), + ( + r"/api/v2/servers/([0-9]+)/public/?", + ApiServersServerPublicHandler, + handler_args, + ), + ( + r"/api/v2/roles/?", + ApiRolesIndexHandler, + handler_args, + ), + ( + r"/api/v2/roles/([0-9]+)/?", + ApiRolesRoleIndexHandler, + handler_args, + ), + ( + r"/api/v2/roles/([0-9]+)/servers/?", + ApiRolesRoleServersHandler, + handler_args, + ), + ( + r"/api/v2/roles/([0-9]+)/users/?", + ApiRolesRoleUsersHandler, + handler_args, + ), + ( + r"/api/v2/jsonschema/?", + ApiJsonSchemaListHandler, + handler_args, + ), + ( + r"/api/v2/jsonschema/([a-z0-9_]+)/?", + ApiJsonSchemaHandler, + handler_args, + ), + ( + r"/api/v2/?", + ApiIndexHandler, + handler_args, + ), + ( + r"/api/v2/(.*)", + ApiNotFoundHandler, + handler_args, + ), + ] diff --git a/app/classes/web/routes/api/auth/invalidate_tokens.py b/app/classes/web/routes/api/auth/invalidate_tokens.py new file mode 100644 index 00000000..6308afcd --- /dev/null +++ b/app/classes/web/routes/api/auth/invalidate_tokens.py @@ -0,0 +1,21 @@ +import datetime +import logging +from app.classes.shared.console import Console +from app.classes.web.base_api_handler import BaseApiHandler + +logger = logging.getLogger(__name__) + + +class ApiAuthInvalidateTokensHandler(BaseApiHandler): + def post(self): + auth_data = self.authenticate_user() + if not auth_data: + return + + # TODO: Invalidate tokens + Console.info("invalidate_tokens") + self.controller.users.raw_update_user( + auth_data[4]["user_id"], {"valid_tokens_from": datetime.datetime.now()} + ) + + self.finish_json(200, {"status": "ok"}) diff --git a/app/classes/web/routes/api/auth/login.py b/app/classes/web/routes/api/auth/login.py new file mode 100644 index 00000000..8583dce5 --- /dev/null +++ b/app/classes/web/routes/api/auth/login.py @@ -0,0 +1,104 @@ +import logging +import json +from jsonschema import validate +from jsonschema.exceptions import ValidationError +from app.classes.models.users import Users +from app.classes.shared.helpers import Helpers +from app.classes.web.base_api_handler import BaseApiHandler + +logger = logging.getLogger(__name__) + +login_schema = { + "type": "object", + "properties": { + "username": { + "type": "string", + "maxLength": 20, + "minLength": 4, + "pattern": "^[a-z0-9_]+$", + }, + "password": {"type": "string", "maxLength": 20, "minLength": 4}, + }, + "required": ["username", "password"], + "additionalProperties": False, +} + + +class ApiAuthLoginHandler(BaseApiHandler): + def post(self): + + try: + data = json.loads(self.request.body) + except json.decoder.JSONDecodeError as e: + return self.finish_json( + 400, {"status": "error", "error": "INVALID_JSON", "error_data": str(e)} + ) + + try: + validate(data, login_schema) + except ValidationError as e: + return self.finish_json( + 400, + { + "status": "error", + "error": "INVALID_JSON_SCHEMA", + "error_data": str(e), + }, + ) + + username = data["username"] + password = data["password"] + + # pylint: disable=no-member + user_data = Users.get_or_none(Users.username == username) + + if user_data is None: + return self.finish_json( + 401, + {"status": "error", "error": "INCORRECT_CREDENTIALS", "token": None}, + ) + + if not user_data.enabled: + self.finish_json( + 403, {"status": "error", "error": "ACCOUNT_DISABLED", "token": None} + ) + return + + login_result = self.helper.verify_pass(password, user_data.password) + + # Valid Login + if login_result: + logger.info(f"User: {user_data} Logged in from IP: {self.get_remote_ip()}") + + # record this login + query = Users.select().where(Users.username == username.lower()).get() + query.last_ip = self.get_remote_ip() + query.last_login = Helpers.get_time_as_string() + query.save() + + # log this login + self.controller.management.add_to_audit_log( + user_data.user_id, "logged in via the API", 0, self.get_remote_ip() + ) + + self.finish_json( + 200, + { + "status": "ok", + "data": { + "token": self.controller.authentication.generate( + user_data.user_id + ), + "user_id": str(user_data.user_id), + }, + }, + ) + else: + # log this failed login attempt + self.controller.management.add_to_audit_log( + user_data.user_id, "Tried to log in", 0, self.get_remote_ip() + ) + self.finish_json( + 401, + {"status": "error", "error": "INCORRECT_CREDENTIALS"}, + ) diff --git a/app/classes/web/routes/api/auth/register.py b/app/classes/web/routes/api/auth/register.py new file mode 100644 index 00000000..31de5f5f --- /dev/null +++ b/app/classes/web/routes/api/auth/register.py @@ -0,0 +1,2 @@ +# nothing here yet +# sometime implement configurable self service account creation? diff --git a/app/classes/web/routes/api/index_handler.py b/app/classes/web/routes/api/index_handler.py new file mode 100644 index 00000000..c8168b46 --- /dev/null +++ b/app/classes/web/routes/api/index_handler.py @@ -0,0 +1,17 @@ +from app.classes.web.base_api_handler import BaseApiHandler + +WIKI_API_LINK = "https://wiki.craftycontrol.com/en/4/docs/API V2" + + +class ApiIndexHandler(BaseApiHandler): + def get(self): + self.finish_json( + 200, + { + "status": "ok", + "data": { + "version": self.controller.helper.get_version_string(), + "message": f"Please see the API documentation at {WIKI_API_LINK}", + }, + }, + ) diff --git a/app/classes/web/routes/api/jsonschema.py b/app/classes/web/routes/api/jsonschema.py new file mode 100644 index 00000000..f3676645 --- /dev/null +++ b/app/classes/web/routes/api/jsonschema.py @@ -0,0 +1,107 @@ +import typing as t +from app.classes.web.base_api_handler import BaseApiHandler +from app.classes.web.routes.api.auth.login import login_schema +from app.classes.web.routes.api.roles.role.index import modify_role_schema +from app.classes.web.routes.api.roles.index import create_role_schema +from app.classes.web.routes.api.servers.server.index import server_patch_schema +from app.classes.web.routes.api.servers.index import new_server_schema + +SCHEMA_LIST: t.Final = [ + "login", + "modify_role", + "create_role", + "server_patch", + "new_server", + "user_patch", + "new_user", +] + + +class ApiJsonSchemaListHandler(BaseApiHandler): + def get(self): + self.finish_json( + 200, + {"status": "ok", "data": SCHEMA_LIST}, + ) + + +class ApiJsonSchemaHandler(BaseApiHandler): + def get(self, schema_name: str): + if schema_name == "login": + self.finish_json( + 200, + {"status": "ok", "data": login_schema}, + ) + elif schema_name == "modify_role": + self.finish_json( + 200, + {"status": "ok", "data": modify_role_schema}, + ) + elif schema_name == "create_role": + self.finish_json( + 200, + {"status": "ok", "data": create_role_schema}, + ) + elif schema_name == "server_patch": + self.finish_json(200, server_patch_schema) + elif schema_name == "new_server": + self.finish_json( + 200, + new_server_schema, + ) + elif schema_name == "user_patch": + self.finish_json( + 200, + { + "status": "ok", + "data": { + "type": "object", + "properties": { + **self.controller.users.user_jsonschema_props, + }, + "anyOf": [ + # Require at least one property + {"required": [name]} + for name in [ + "username", + "password", + "email", + "enabled", + "lang", + "superuser", + "permissions", + "roles", + "hints", + ] + ], + "additionalProperties": False, + }, + }, + ) + elif schema_name == "new_user": + self.finish_json( + 200, + { + "status": "ok", + "data": { + "type": "object", + "properties": { + **self.controller.users.user_jsonschema_props, + }, + "required": ["username", "password"], + "additionalProperties": False, + }, + }, + ) + else: + self.finish_json( + 404, + { + "status": "error", + "error": "UNKNOWN_JSON_SCHEMA", + "info": ( + f"Unknown JSON schema: {schema_name}." + f" Here's a list of all the valid schema names: {SCHEMA_LIST}" + ), + }, + ) diff --git a/app/classes/web/routes/api/not_found.py b/app/classes/web/routes/api/not_found.py new file mode 100644 index 00000000..ae5a1cb9 --- /dev/null +++ b/app/classes/web/routes/api/not_found.py @@ -0,0 +1,18 @@ +from typing import Awaitable, Callable, Optional +from app.classes.web.base_api_handler import BaseApiHandler + + +class ApiNotFoundHandler(BaseApiHandler): + def _not_found(self, page: str) -> None: + self.finish_json( + 404, + {"status": "error", "error": "API_HANDLER_NOT_FOUND", "page": page}, + ) + + head = _not_found # type: Callable[..., Optional[Awaitable[None]]] + get = _not_found # type: Callable[..., Optional[Awaitable[None]]] + post = _not_found # type: Callable[..., Optional[Awaitable[None]]] + delete = _not_found # type: Callable[..., Optional[Awaitable[None]]] + patch = _not_found # type: Callable[..., Optional[Awaitable[None]]] + put = _not_found # type: Callable[..., Optional[Awaitable[None]]] + options = _not_found # type: Callable[..., Optional[Awaitable[None]]] diff --git a/app/classes/web/routes/api/roles/index.py b/app/classes/web/routes/api/roles/index.py new file mode 100644 index 00000000..d98d8d53 --- /dev/null +++ b/app/classes/web/routes/api/roles/index.py @@ -0,0 +1,131 @@ +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): + auth_data = self.authenticate_user() + if not auth_data: + return + ( + _, + _, + _, + superuser, + _, + ) = auth_data + + # GET /api/v2/roles?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"}) + + self.finish_json( + 200, + { + "status": "ok", + "data": self.controller.roles.get_all_role_ids() + if get_only_ids + 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 = ( + ( + {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 new file mode 100644 index 00000000..8dbb7373 --- /dev/null +++ b/app/classes/web/routes/api/roles/role/index.py @@ -0,0 +1,143 @@ +from jsonschema import ValidationError, validate +import orjson +from peewee import DoesNotExist +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": "integer", + "minimum": 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): + 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: + self.finish_json( + 200, + {"status": "ok", "data": self.controller.roles.get_role(role_id)}, + ) + except DoesNotExist: + self.finish_json(404, {"status": "error", "error": "ROLE_NOT_FOUND"}) + + def delete(self, role_id: str): + 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"}) + + self.controller.roles.remove_role(role_id) + + self.finish_json( + 200, + {"status": "ok", "data": role_id}, + ) + + self.controller.management.add_to_audit_log( + user["user_id"], + f"deleted role with ID {role_id}", + server_id=0, + source_ip=self.get_remote_ip(), + ) + + def patch(self, role_id: str): + 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, modify_role_schema) + except ValidationError as e: + return self.finish_json( + 400, + { + "status": "error", + "error": "INVALID_JSON_SCHEMA", + "error_data": str(e), + }, + ) + + try: + self.controller.roles.update_role_advanced( + role_id, data.get("role_name", None), data.get("servers", None) + ) + except DoesNotExist: + return self.finish_json(404, {"status": "error", "error": "ROLE_NOT_FOUND"}) + + self.controller.management.add_to_audit_log( + user["user_id"], + f"modified role with ID {role_id}", + server_id=0, + source_ip=self.get_remote_ip(), + ) + + self.finish_json( + 200, + {"status": "ok"}, + ) diff --git a/app/classes/web/routes/api/roles/role/servers.py b/app/classes/web/routes/api/roles/role/servers.py new file mode 100644 index 00000000..b9b920ca --- /dev/null +++ b/app/classes/web/routes/api/roles/role/servers.py @@ -0,0 +1,32 @@ +from app.classes.models.server_permissions import PermissionsServers +from app.classes.web.base_api_handler import BaseApiHandler + + +class ApiRolesRoleServersHandler(BaseApiHandler): + def get(self, role_id: str): + auth_data = self.authenticate_user() + if not auth_data: + return + ( + _, + _, + _, + superuser, + _, + ) = 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"}) + + self.finish_json( + 200, + { + "status": "ok", + "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/roles/role/users.py b/app/classes/web/routes/api/roles/role/users.py new file mode 100644 index 00000000..ac2227ac --- /dev/null +++ b/app/classes/web/routes/api/roles/role/users.py @@ -0,0 +1,36 @@ +from app.classes.web.base_api_handler import BaseApiHandler + + +class ApiRolesRoleUsersHandler(BaseApiHandler): + def get(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"}) + + all_user_ids = self.controller.users.get_all_user_ids() + + user_roles = {} + for user_id in all_user_ids: + user_roles_list = self.controller.users.get_user_roles_names(user_id) + user_roles[user_id] = user_roles_list + + role = self.controller.roles.get_role(role_id) + + user_ids = [] + + for user_id in all_user_ids: + for role_user in user_roles[user_id]: + if role_user == role["role_name"]: + user_ids.append(user_id) + + self.finish_json(200, {"status": "ok", "data": user_ids}) diff --git a/app/classes/web/routes/api/servers/index.py b/app/classes/web/routes/api/servers/index.py new file mode 100644 index 00000000..04172386 --- /dev/null +++ b/app/classes/web/routes/api/servers/index.py @@ -0,0 +1,713 @@ +import logging + +from jsonschema import ValidationError, validate +import orjson +from app.classes.models.crafty_permissions import EnumPermissionsCrafty +from app.classes.web.base_api_handler import BaseApiHandler + +logger = logging.getLogger(__name__) + +new_server_schema = { + "definitions": {}, + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Root", + "type": "object", + "required": [ + "name", + "monitoring_type", + "create_type", + ], + "examples": [ + { + "name": "My Server", + "monitoring_type": "minecraft_java", + "minecraft_java_monitoring_data": {"host": "127.0.0.1", "port": 25565}, + "create_type": "minecraft_java", + "minecraft_java_create_data": { + "create_type": "download_jar", + "download_jar_create_data": { + "type": "Paper", + "version": "1.18.2", + "mem_min": 1, + "mem_max": 2, + "server_properties_port": 25565, + }, + }, + } + ], + "properties": { + "name": { + "title": "Name", + "type": "string", + "examples": ["My Server"], + "minLength": 2, + }, + "stop_command": { + "title": "Stop command", + "description": '"" means the default for the server creation type.', + "type": "string", + "default": "", + "examples": ["stop", "end"], + }, + "log_location": { + "title": "Log file", + "description": '"" means the default for the server creation type.', + "type": "string", + "default": "", + "examples": ["./logs/latest.log", "./proxy.log.0"], + }, + "crashdetection": { + "title": "Crash detection", + "type": "boolean", + "default": False, + }, + "autostart": { + "title": "Autostart", + "description": "If true, the server will be started" + + " automatically when Crafty is launched.", + "type": "boolean", + "default": False, + }, + "autostart_delay": { + "title": "Autostart delay", + "description": "Delay in seconds before autostarting. (If enabled)", + "type": "number", + "default": 10, + "minimum": 0, + }, + "monitoring_type": { + "title": "Server monitoring type", + "type": "string", + "default": "minecraft_java", + "enum": ["minecraft_java", "minecraft_bedrock", "none"], + # TODO: SteamCMD, RakNet, etc. + }, + "minecraft_java_monitoring_data": { + "title": "Minecraft Java monitoring data", + "type": "object", + "required": ["host", "port"], + "properties": { + "host": { + "title": "Host", + "type": "string", + "default": "127.0.0.1", + "examples": ["127.0.0.1"], + "minLength": 1, + }, + "port": { + "title": "Port", + "type": "integer", + "examples": [25565], + "default": 25565, + "minimum": 0, + }, + }, + }, + "minecraft_bedrock_monitoring_data": { + "title": "Minecraft Bedrock monitoring data", + "type": "object", + "required": ["host", "port"], + "properties": { + "host": { + "title": "Host", + "type": "string", + "default": "127.0.0.1", + "examples": ["127.0.0.1"], + "minLength": 1, + }, + "port": { + "title": "Port", + "type": "integer", + "examples": [19132], + "default": 19132, + "minimum": 0, + }, + }, + }, + "create_type": { + # This is only used for creation, this is not saved in the db + "title": "Server creation type", + "type": "string", + "default": "minecraft_java", + "enum": ["minecraft_java", "minecraft_bedrock", "custom"], + }, + "minecraft_java_create_data": { + "title": "Java creation data", + "type": "object", + "required": ["create_type"], + "properties": { + "create_type": { + "title": "Creation type", + "type": "string", + "default": "download_jar", + "enum": ["download_jar", "import_server", "import_zip"], + }, + "download_jar_create_data": { + "title": "JAR download data", + "type": "object", + "required": [ + "type", + "version", + "mem_min", + "mem_max", + "server_properties_port", + "agree_to_eula", + ], + "properties": { + "type": { + "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)", + "type": "number", + "examples": [1], + "default": 1, + "exclusiveMinimum": 0, + }, + "mem_max": { + "title": "Maximum JVM memory (in GiBs)", + "type": "number", + "examples": [2], + "default": 2, + "exclusiveMinimum": 0, + }, + "server_properties_port": { + "title": "Port", + "type": "integer", + "examples": [25565], + "default": 25565, + "minimum": 0, + }, + "agree_to_eula": { + "title": "Agree to the EULA", + "type": "boolean", + "default": False, + }, + }, + }, + "import_server_create_data": { + "title": "Import server data", + "type": "object", + "required": [ + "existing_server_path", + "jarfile", + "mem_min", + "mem_max", + "server_properties_port", + "agree_to_eula", + ], + "properties": { + "existing_server_path": { + "title": "Server path", + "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)", + "type": "number", + "examples": [1], + "default": 1, + "exclusiveMinimum": 0, + }, + "mem_max": { + "title": "Maximum JVM memory (in GiBs)", + "type": "number", + "examples": [2], + "default": 2, + "exclusiveMinimum": 0, + }, + "server_properties_port": { + "title": "Port", + "type": "integer", + "examples": [25565], + "default": 25565, + "minimum": 0, + }, + "agree_to_eula": { + "title": "Agree to the EULA", + "type": "boolean", + "default": False, + }, + }, + }, + "import_zip_create_data": { + "title": "Import ZIP server data", + "type": "object", + "required": [ + "zip_path", + "zip_root", + "jarfile", + "mem_min", + "mem_max", + "server_properties_port", + "agree_to_eula", + ], + "properties": { + "zip_path": { + "title": "ZIP path", + "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)", + "type": "number", + "examples": [1], + "default": 1, + "exclusiveMinimum": 0, + }, + "mem_max": { + "title": "Maximum JVM memory (in GiBs)", + "type": "number", + "examples": [2], + "default": 2, + "exclusiveMinimum": 0, + }, + "server_properties_port": { + "title": "Port", + "type": "integer", + "examples": [25565], + "default": 25565, + "minimum": 0, + }, + "agree_to_eula": { + "title": "Agree to the EULA", + "type": "boolean", + "default": False, + }, + }, + }, + }, + "allOf": [ + { + "$comment": "If..then section", + "allOf": [ + { + "if": { + "properties": {"create_type": {"const": "download_jar"}} + }, + "then": {"required": ["download_jar_create_data"]}, + }, + { + "if": { + "properties": {"create_type": {"const": "import_exec"}} + }, + "then": {"required": ["import_server_create_data"]}, + }, + { + "if": { + "properties": {"create_type": {"const": "import_zip"}} + }, + "then": {"required": ["import_zip_create_data"]}, + }, + ], + }, + { + "title": "Only one creation data", + "oneOf": [ + {"required": ["download_jar_create_data"]}, + {"required": ["import_server_create_data"]}, + {"required": ["import_zip_create_data"]}, + ], + }, + ], + }, + "minecraft_bedrock_create_data": { + "title": "Minecraft Bedrock creation data", + "type": "object", + "required": ["create_type"], + "properties": { + "create_type": { + "title": "Creation type", + "type": "string", + "default": "import_server", + "enum": ["import_server", "import_zip"], + }, + "import_server_create_data": { + "title": "Import server data", + "type": "object", + "required": ["existing_server_path", "command"], + "properties": { + "existing_server_path": { + "title": "Server path", + "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, + }, + }, + }, + "import_zip_create_data": { + "title": "Import ZIP server data", + "type": "object", + "required": ["zip_path", "zip_root", "command"], + "properties": { + "zip_path": { + "title": "ZIP path", + "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, + }, + }, + }, + }, + "allOf": [ + { + "$comment": "If..then section", + "allOf": [ + { + "if": { + "properties": {"create_type": {"const": "import_exec"}} + }, + "then": {"required": ["import_server_create_data"]}, + }, + { + "if": { + "properties": {"create_type": {"const": "import_zip"}} + }, + "then": {"required": ["import_zip_create_data"]}, + }, + ], + }, + { + "title": "Only one creation data", + "oneOf": [ + {"required": ["import_server_create_data"]}, + {"required": ["import_zip_create_data"]}, + ], + }, + ], + }, + "custom_create_data": { + "title": "Custom creation data", + "type": "object", + "required": [ + "working_directory", + "executable_update", + "create_type", + ], + "properties": { + "working_directory": { + "title": "Working directory", + "description": '"" means the default', + "type": "string", + "default": "", + "examples": ["/mnt/mydrive/server-configs/", "./subdirectory", ""], + }, + "executable_update": { + "title": "Executable Updation", + "description": "Also configurable later on and for other servers", + "type": "object", + "required": ["enabled", "file", "url"], + "properties": { + "enabled": { + "title": "Enabled", + "type": "boolean", + "default": False, + }, + "file": { + "title": "Executable to update", + "type": "string", + "default": "", + "examples": ["./paper.jar"], + }, + "url": { + "title": "URL to download the executable from", + "type": "string", + "default": "", + }, + }, + }, + "create_type": { + "title": "Creation type", + "type": "string", + "default": "raw_exec", + "enum": ["raw_exec", "import_server", "import_zip"], + }, + "raw_exec_create_data": { + "title": "Raw execution command create data", + "type": "object", + "required": ["command"], + "properties": { + "command": { + "title": "Command", + "type": "string", + "default": "echo foo bar baz", + "examples": ["caddy start"], + "minLength": 1, + } + }, + }, + "import_server_create_data": { + "title": "Import server data", + "type": "object", + "required": ["existing_server_path", "command"], + "properties": { + "existing_server_path": { + "title": "Server path", + "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, + }, + }, + }, + "import_zip_create_data": { + "title": "Import ZIP server data", + "type": "object", + "required": ["zip_path", "zip_root", "command"], + "properties": { + "zip_path": { + "title": "ZIP path", + "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, + }, + }, + }, + }, + "allOf": [ + { + "$comment": "If..then section", + "allOf": [ + { + "if": { + "properties": {"create_type": {"const": "raw_exec"}} + }, + "then": {"required": ["raw_exec_create_data"]}, + }, + { + "if": { + "properties": { + "create_type": {"const": "import_server"} + } + }, + "then": {"required": ["import_server_create_data"]}, + }, + { + "if": { + "properties": {"create_type": {"const": "import_zip"}} + }, + "then": {"required": ["import_zip_create_data"]}, + }, + ], + }, + { + "title": "Only one creation data", + "oneOf": [ + {"required": ["raw_exec_create_data"]}, + {"required": ["import_server_create_data"]}, + {"required": ["import_zip_create_data"]}, + ], + }, + ], + }, + }, + "allOf": [ + { + "$comment": "If..then section", + "allOf": [ + # start require creation data + { + "if": {"properties": {"create_type": {"const": "minecraft_java"}}}, + "then": {"required": ["minecraft_java_create_data"]}, + }, + { + "if": { + "properties": {"create_type": {"const": "minecraft_bedrock"}} + }, + "then": {"required": ["minecraft_bedrock_create_data"]}, + }, + { + "if": {"properties": {"create_type": {"const": "custom"}}}, + "then": {"required": ["custom_create_data"]}, + }, + # end require creation data + # start require monitoring data + { + "if": { + "properties": {"monitoring_type": {"const": "minecraft_java"}} + }, + "then": {"required": ["minecraft_java_monitoring_data"]}, + }, + { + "if": { + "properties": { + "monitoring_type": {"const": "minecraft_bedrock"} + } + }, + "then": {"required": ["minecraft_bedrock_monitoring_data"]}, + }, + # end require monitoring data + ], + }, + { + "title": "Only one creation data", + "oneOf": [ + {"required": ["minecraft_java_create_data"]}, + {"required": ["minecraft_bedrock_create_data"]}, + {"required": ["custom_create_data"]}, + ], + }, + { + "title": "Only one monitoring data", + "oneOf": [ + {"required": ["minecraft_java_monitoring_data"]}, + {"required": ["minecraft_bedrock_monitoring_data"]}, + {"properties": {"monitoring_type": {"const": "none"}}}, + ], + }, + ], +} + + +class ApiServersIndexHandler(BaseApiHandler): + def get(self): + auth_data = self.authenticate_user() + if not auth_data: + return + + # TODO: limit some columns for specific permissions + + self.finish_json(200, {"status": "ok", "data": auth_data[0]}) + + def post(self): + + auth_data = self.authenticate_user() + if not auth_data: + return + ( + _, + exec_user_crafty_permissions, + _, + _superuser, + user, + ) = auth_data + + if EnumPermissionsCrafty.SERVER_CREATION not in exec_user_crafty_permissions: + 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, new_server_schema) + except ValidationError as e: + return self.finish_json( + 400, + { + "status": "error", + "error": "INVALID_JSON_SCHEMA", + "error_data": str(e), + }, + ) + + new_server_id, new_server_uuid = self.controller.create_api_server(data) + + # Increase the server creation counter + self.controller.crafty_perms.add_server_creation(user["user_id"]) + + self.controller.stats.record_stats() + + self.controller.management.add_to_audit_log( + user["user_id"], + ( + f"created server {data['name']}" + f" (ID: {new_server_id})" + f" (UUID: {new_server_uuid})" + ), + server_id=new_server_id, + source_ip=self.get_remote_ip(), + ) + + self.finish_json( + 201, + { + "status": "ok", + "data": { + "new_server_id": str(new_server_id), + "new_server_uuid": new_server_uuid, + }, + }, + ) diff --git a/app/classes/web/routes/api/servers/server/action.py b/app/classes/web/routes/api/servers/server/action.py new file mode 100644 index 00000000..b8728b1e --- /dev/null +++ b/app/classes/web/routes/api/servers/server/action.py @@ -0,0 +1,98 @@ +import logging +import os +from app.classes.models.server_permissions import EnumPermissionsServer +from app.classes.models.servers import Servers +from app.classes.shared.file_helpers import FileHelpers +from app.classes.shared.helpers import Helpers +from app.classes.web.base_api_handler import BaseApiHandler + + +logger = logging.getLogger(__name__) + + +class ApiServersServerActionHandler(BaseApiHandler): + def post(self, server_id: str, action: str): + auth_data = self.authenticate_user() + if not auth_data: + return + + if server_id not in [str(x["server_id"]) for x in auth_data[0]]: + # if the user doesn't have access to the server, return an error + return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"}) + + if ( + EnumPermissionsServer.COMMANDS + not in self.controller.server_perms.get_user_id_permissions_list( + auth_data[4]["user_id"], server_id + ) + ): + # if the user doesn't have Commands permission, return an error + return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"}) + + if action == "clone_server": + return self._clone_server(server_id, auth_data[4]["user_id"]) + + self.controller.management.send_command( + auth_data[4]["user_id"], server_id, self.get_remote_ip(), action + ) + + self.finish_json( + 200, + {"status": "ok"}, + ) + + def _clone_server(self, server_id, user_id): + def is_name_used(name): + return Servers.select().where(Servers.server_name == name).count() != 0 + + server_data = self.controller.servers.get_server_data_by_id(server_id) + server_uuid = server_data.get("server_uuid") + new_server_name = server_data.get("server_name") + " (Copy)" + + name_counter = 1 + while is_name_used(new_server_name): + name_counter += 1 + new_server_name = server_data.get("server_name") + f" (Copy {name_counter})" + + new_server_uuid = Helpers.create_uuid() + while os.path.exists(os.path.join(self.helper.servers_dir, new_server_uuid)): + new_server_uuid = Helpers.create_uuid() + new_server_path = os.path.join(self.helper.servers_dir, new_server_uuid) + + self.controller.management.add_to_audit_log( + user_id, + f"is cloning server {server_id} named {server_data.get('server_name')}", + server_id, + self.get_remote_ip(), + ) + + # copy the old server + FileHelpers.copy_dir(server_data.get("path"), new_server_path) + + # TODO get old server DB data to individual variables + new_server_command = str(server_data.get("execution_command")).replace( + server_uuid, new_server_uuid + ) + new_server_log_file = str( + self.helper.get_os_understandable_path(server_data.get("log_path")) + ).replace(server_uuid, new_server_uuid) + + new_server_id = self.controller.servers.create_server( + new_server_name, + new_server_uuid, + new_server_path, + "", + new_server_command, + server_data.get("executable"), + new_server_log_file, + server_data.get("stop_command"), + server_data.get("type"), + server_data.get("server_port"), + ) + + self.controller.init_all_servers() + + self.finish_json( + 200, + {"status": "ok", "data": {"new_server_id": str(new_server_id)}}, + ) diff --git a/app/classes/web/routes/api/servers/server/index.py b/app/classes/web/routes/api/servers/server/index.py new file mode 100644 index 00000000..962f9380 --- /dev/null +++ b/app/classes/web/routes/api/servers/server/index.py @@ -0,0 +1,168 @@ +import logging +import json +from jsonschema import validate +from jsonschema.exceptions import ValidationError +from playhouse.shortcuts import model_to_dict +from app.classes.models.server_permissions import EnumPermissionsServer +from app.classes.web.base_api_handler import BaseApiHandler + +logger = logging.getLogger(__name__) + +# TODO: modify monitoring +server_patch_schema = { + "type": "object", + "properties": { + "server_name": {"type": "string", "minLength": 1}, + "path": {"type": "string", "minLength": 1}, + "backup_path": {"type": "string"}, + "executable": {"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", "minLength": 1}, + "server_ip": {"type": "string", "minLength": 1}, + "server_port": {"type": "integer"}, + "logs_delete_after": {"type": "integer"}, + "type": {"type": "string", "minLength": 1}, + }, + "anyOf": [ + # Require at least one property + {"required": [name]} + for name in [ + "server_name", + "path", + "backup_path", + "executable", + "log_path", + "execution_command", + "auto_start", + "auto_start_delay", + "crash_detection", + "stop_command", + "executable_update_url", + "server_ip", + "server_port", + "logs_delete_after", + "type", + ] + ], + "additionalProperties": False, +} + + +class ApiServersServerIndexHandler(BaseApiHandler): + def get(self, server_id: str): + auth_data = self.authenticate_user() + if not auth_data: + return + + if server_id not in [str(x["server_id"]) for x in auth_data[0]]: + # if the user doesn't have access to the server, return an error + return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"}) + + server_obj = self.controller.servers.get_server_obj(server_id) + server = model_to_dict(server_obj) + + # TODO: limit some columns for specific permissions? + + self.finish_json(200, {"status": "ok", "data": server}) + + def patch(self, server_id: str): + auth_data = self.authenticate_user() + if not auth_data: + return + + try: + data = json.loads(self.request.body) + except json.decoder.JSONDecodeError as e: + return self.finish_json( + 400, {"status": "error", "error": "INVALID_JSON", "error_data": str(e)} + ) + + try: + validate(data, server_patch_schema) + except ValidationError as e: + return self.finish_json( + 400, + { + "status": "error", + "error": "INVALID_JSON_SCHEMA", + "error_data": str(e), + }, + ) + + if server_id not in [str(x["server_id"]) for x in auth_data[0]]: + # if the user doesn't have access to the server, return an error + return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"}) + + if ( + EnumPermissionsServer.CONFIG + not in self.controller.server_perms.get_user_id_permissions_list( + auth_data[4]["user_id"], server_id + ) + ): + # if the user doesn't have Config permission, return an error + return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"}) + + server_obj = self.controller.servers.get_server_obj(server_id) + for key in data: + # If we don't validate the input there could be security issues + setattr(self, key, data[key]) + self.controller.servers.update_server(server_obj) + + self.controller.management.add_to_audit_log( + auth_data[4]["user_id"], + f"modified the server with ID {server_id}", + server_id, + self.get_remote_ip(), + ) + + return self.finish_json(200, {"status": "ok"}) + + def delete(self, server_id: str): + auth_data = self.authenticate_user() + if not auth_data: + return + + # DELETE /api/v2/servers/server?files=true + remove_files = self.get_query_argument("files", None) == "true" + + if server_id not in [str(x["server_id"]) for x in auth_data[0]]: + # if the user doesn't have access to the server, return an error + return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"}) + + if ( + EnumPermissionsServer.CONFIG + not in self.controller.server_perms.get_user_id_permissions_list( + auth_data[4]["user_id"], server_id + ) + ): + # if the user doesn't have Config permission, return an error + return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"}) + + logger.info( + ( + "Removing server and all associated files for server: " + if remove_files + else "Removing server from panel for server: " + ) + + self.controller.servers.get_server_friendly_name(server_id) + ) + + self.tasks_manager.remove_all_server_tasks(server_id) + self.controller.remove_server(server_id, remove_files) + + self.controller.management.add_to_audit_log( + auth_data[4]["user_id"], + f"deleted the server {server_id}", + server_id, + self.get_remote_ip(), + ) + + self.finish_json( + 200, + {"status": "ok"}, + ) diff --git a/app/classes/web/routes/api/servers/server/logs.py b/app/classes/web/routes/api/servers/server/logs.py new file mode 100644 index 00000000..efb18630 --- /dev/null +++ b/app/classes/web/routes/api/servers/server/logs.py @@ -0,0 +1,73 @@ +import html +import logging +import re +from app.classes.models.server_permissions import EnumPermissionsServer +from app.classes.shared.server import ServerOutBuf +from app.classes.web.base_api_handler import BaseApiHandler + + +logger = logging.getLogger(__name__) + + +class ApiServersServerLogsHandler(BaseApiHandler): + def get(self, server_id: str): + auth_data = self.authenticate_user() + if not auth_data: + return + + # GET /api/v2/servers/server/logs?file=true + read_log_file = self.get_query_argument("file", None) == "true" + # GET /api/v2/servers/server/logs?colors=true + colored_output = self.get_query_argument("colors", None) == "true" + # GET /api/v2/servers/server/logs?raw=true + disable_ansi_strip = self.get_query_argument("raw", None) == "true" + # GET /api/v2/servers/server/logs?html=true + use_html = self.get_query_argument("html", None) == "true" + + if server_id not in [str(x["server_id"]) for x in auth_data[0]]: + # if the user doesn't have access to the server, return an error + return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"}) + + if ( + EnumPermissionsServer.LOGS + not in self.controller.server_perms.get_user_id_permissions_list( + auth_data[4]["user_id"], server_id + ) + ): + # if the user doesn't have Logs permission, return an error + return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"}) + + server_data = self.controller.servers.get_server_data_by_id(server_id) + + if read_log_file: + log_lines = self.helper.get_setting("max_log_lines") + raw_lines = self.helper.tail_file( + self.helper.get_os_understandable_path(server_data["log_path"]), + log_lines, + ) + else: + raw_lines = ServerOutBuf.lines.get(server_id, []) + + lines = [] + + for line in raw_lines: + try: + if not disable_ansi_strip: + line = re.sub( + "(\033\\[(0;)?[0-9]*[A-z]?(;[0-9])?m?)|(> )", "", line + ) + line = re.sub("[A-z]{2}\b\b", "", line) + line = html.escape(line) + + if colored_output: + line = self.helper.log_colors(line) + + lines.append(line) + except Exception as e: + logger.warning(f"Skipping Log Line due to error: {e}") + + if use_html: + for line in lines: + self.write(f"{line}
") + else: + self.finish_json(200, {"status": "ok", "data": lines}) diff --git a/app/classes/web/routes/api/servers/server/public.py b/app/classes/web/routes/api/servers/server/public.py new file mode 100644 index 00000000..17f1d36c --- /dev/null +++ b/app/classes/web/routes/api/servers/server/public.py @@ -0,0 +1,23 @@ +import logging +from app.classes.web.base_api_handler import BaseApiHandler + +logger = logging.getLogger(__name__) + + +class ApiServersServerPublicHandler(BaseApiHandler): + def get(self, server_id): + auth_data = self.authenticate_user() + if not auth_data: + return + server_obj = self.controller.servers.get_server_obj(server_id) + + self.finish_json( + 200, + { + "status": "ok", + "data": { + key: getattr(server_obj, key) + for key in ["server_id", "created", "server_name", "type"] + }, + }, + ) diff --git a/app/classes/web/routes/api/servers/server/stats.py b/app/classes/web/routes/api/servers/server/stats.py new file mode 100644 index 00000000..2a8bc23e --- /dev/null +++ b/app/classes/web/routes/api/servers/server/stats.py @@ -0,0 +1,28 @@ +import logging +from playhouse.shortcuts import model_to_dict +from app.classes.models.server_stats import HelperServerStats +from app.classes.web.base_api_handler import BaseApiHandler + + +logger = logging.getLogger(__name__) + + +class ApiServersServerStatsHandler(BaseApiHandler): + def get(self, server_id: str): + auth_data = self.authenticate_user() + if not auth_data: + return + + if server_id not in [str(x["server_id"]) for x in auth_data[0]]: + # if the user doesn't have access to the server, return an error + return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"}) + + self.finish_json( + 200, + { + "status": "ok", + "data": model_to_dict( + HelperServerStats.get_latest_server_stats(server_id)[0] + ), + }, + ) diff --git a/app/classes/web/routes/api/servers/server/users.py b/app/classes/web/routes/api/servers/server/users.py new file mode 100644 index 00000000..c4df8832 --- /dev/null +++ b/app/classes/web/routes/api/servers/server/users.py @@ -0,0 +1,31 @@ +import logging +from app.classes.models.crafty_permissions import EnumPermissionsCrafty +from app.classes.web.base_api_handler import BaseApiHandler + + +logger = logging.getLogger(__name__) + + +class ApiServersServerUsersHandler(BaseApiHandler): + def get(self, server_id: str): + auth_data = self.authenticate_user() + if not auth_data: + return + + if server_id not in [str(x["server_id"]) for x in auth_data[0]]: + # if the user doesn't have access to the server, return an error + return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"}) + + if EnumPermissionsCrafty.USER_CONFIG not in auth_data[1]: + return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"}) + + if EnumPermissionsCrafty.ROLES_CONFIG not in auth_data[1]: + return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"}) + + self.finish_json( + 200, + { + "status": "ok", + "data": list(self.controller.servers.get_authorized_users(server_id)), + }, + ) diff --git a/app/classes/web/routes/api/users/index.py b/app/classes/web/routes/api/users/index.py new file mode 100644 index 00000000..d2a9724e --- /dev/null +++ b/app/classes/web/routes/api/users/index.py @@ -0,0 +1,164 @@ +import logging +import json +from jsonschema import validate +from jsonschema.exceptions import ValidationError +from app.classes.models.crafty_permissions import EnumPermissionsCrafty +from app.classes.models.roles import Roles, HelperRoles +from app.classes.models.users import PUBLIC_USER_ATTRS +from app.classes.web.base_api_handler import BaseApiHandler + +logger = logging.getLogger(__name__) + + +class ApiUsersIndexHandler(BaseApiHandler): + def get(self): + auth_data = self.authenticate_user() + if not auth_data: + return + ( + _, + exec_user_crafty_permissions, + _, + _, + user, + ) = auth_data + + # GET /api/v2/users?ids=true + get_only_ids = self.get_query_argument("ids", None) == "true" + + if EnumPermissionsCrafty.USER_CONFIG in exec_user_crafty_permissions: + if get_only_ids: + data = self.controller.users.get_all_user_ids() + else: + data = [ + {key: getattr(user_res, key) for key in PUBLIC_USER_ATTRS} + for user_res in self.controller.users.get_all_users().execute() + ] + else: + if get_only_ids: + data = [user["user_id"]] + else: + user_res = self.controller.users.get_user_by_id(user["user_id"]) + user_res["roles"] = list( + map(HelperRoles.get_role, user_res.get("roles", set())) + ) + data = [{key: user_res[key] for key in PUBLIC_USER_ATTRS}] + + self.finish_json( + 200, + { + "status": "ok", + "data": data, + }, + ) + + def post(self): + new_user_schema = { + "type": "object", + "properties": { + **self.controller.users.user_jsonschema_props, + }, + "required": ["username", "password"], + "additionalProperties": False, + } + auth_data = self.authenticate_user() + if not auth_data: + return + ( + _, + exec_user_crafty_permissions, + _, + superuser, + user, + ) = auth_data + + if EnumPermissionsCrafty.USER_CONFIG not in exec_user_crafty_permissions: + return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"}) + + try: + data = json.loads(self.request.body) + except json.decoder.JSONDecodeError as e: + return self.finish_json( + 400, {"status": "error", "error": "INVALID_JSON", "error_data": str(e)} + ) + + try: + validate(data, new_user_schema) + except ValidationError as e: + return self.finish_json( + 400, + { + "status": "error", + "error": "INVALID_JSON_SCHEMA", + "error_data": str(e), + }, + ) + + username = data["username"] + password = data["password"] + email = data.get("email", "default@example.com") + enabled = data.get("enabled", True) + lang = data.get("lang", self.helper.get_setting("language")) + superuser = data.get("superuser", False) + permissions = data.get("permissions", None) + roles = data.get("roles", None) + hints = data.get("hints", True) + + if username.lower() in ["system", ""]: + return self.finish_json( + 400, {"status": "error", "error": "INVALID_USERNAME"} + ) + + if self.controller.users.get_id_by_name(username) is not None: + return self.finish_json(400, {"status": "error", "error": "USER_EXISTS"}) + + if roles is None: + roles = set() + else: + role_ids = [str(role_id) for role_id in Roles.select(Roles.role_id)] + roles = {role for role in roles if str(role) in role_ids} + + permissions_mask = "0" * len(EnumPermissionsCrafty.__members__.items()) + server_quantity = { + perm.name: 0 + for perm in self.controller.crafty_perms.list_defined_crafty_permissions() + } + + if permissions is not None: + server_quantity = {} + permissions_mask = list(permissions_mask) + for permission in permissions: + server_quantity[permission["name"]] = permission["quantity"] + permissions_mask[EnumPermissionsCrafty[permission["name"]].value] = ( + "1" if permission["enabled"] else "0" + ) + permissions_mask = "".join(permissions_mask) + + # TODO: do this in the most efficient way + user_id = self.controller.users.add_user( + username, + password, + email, + enabled, + superuser, + ) + self.controller.users.update_user( + user_id, + {"roles": roles, "lang": lang, "hints": hints}, + { + "permissions_mask": permissions_mask, + "server_quantity": server_quantity, + }, + ) + + self.controller.management.add_to_audit_log( + user["user_id"], + f"added user {username} (UID:{user_id}) with roles {roles}", + server_id=0, + source_ip=self.get_remote_ip(), + ) + + self.finish_json( + 201, + {"status": "ok", "data": {"user_id": str(user_id)}}, + ) diff --git a/app/classes/web/routes/api/users/user/index.py b/app/classes/web/routes/api/users/user/index.py new file mode 100644 index 00000000..7dbcbff3 --- /dev/null +++ b/app/classes/web/routes/api/users/user/index.py @@ -0,0 +1,241 @@ +import json +import logging + +from jsonschema import ValidationError, validate +from app.classes.models.crafty_permissions import EnumPermissionsCrafty +from app.classes.models.roles import HelperRoles +from app.classes.models.users import HelperUsers +from app.classes.web.base_api_handler import BaseApiHandler + + +logger = logging.getLogger(__name__) + + +class ApiUsersUserIndexHandler(BaseApiHandler): + def get(self, user_id: str): + auth_data = self.authenticate_user() + if not auth_data: + return + ( + _, + exec_user_crafty_permissions, + _, + _, + user, + ) = auth_data + + if user_id in ["@me", user["user_id"]]: + user_id = user["user_id"] + res_user = user + elif EnumPermissionsCrafty.USER_CONFIG not in exec_user_crafty_permissions: + return self.finish_json( + 400, + { + "status": "error", + "error": "NOT_AUTHORIZED", + }, + ) + else: + # has User_Config permission and isn't viewing self + res_user = self.controller.users.get_user_by_id(user_id) + if not res_user: + return self.finish_json( + 404, + { + "status": "error", + "error": "USER_NOT_FOUND", + }, + ) + + # Remove password and valid_tokens_from from the response + # as those should never be sent out to the client. + res_user.pop("password", None) + res_user.pop("valid_tokens_from", None) + res_user["roles"] = list( + map(HelperRoles.get_role, res_user.get("roles", set())) + ) + + self.finish_json( + 200, + {"status": "ok", "data": res_user}, + ) + + def delete(self, user_id: str): + auth_data = self.authenticate_user() + if not auth_data: + return + ( + _, + exec_user_crafty_permissions, + _, + _, + user, + ) = auth_data + + if (user_id in ["@me", user["user_id"]]) and self.helper.get_setting( + "allow_self_delete", False + ): + user_id = user["user_id"] + self.controller.users.remove_user(user_id) + elif EnumPermissionsCrafty.USER_CONFIG not in exec_user_crafty_permissions: + return self.finish_json( + 400, + { + "status": "error", + "error": "NOT_AUTHORIZED", + }, + ) + else: + # has User_Config permission + self.controller.users.remove_user(user_id) + + self.controller.management.add_to_audit_log( + user["user_id"], + f"deleted the user {user_id}", + server_id=0, + source_ip=self.get_remote_ip(), + ) + + self.finish_json( + 200, + {"status": "ok"}, + ) + + def patch(self, user_id: str): + user_patch_schema = { + "type": "object", + "properties": { + **self.controller.users.user_jsonschema_props, + }, + "anyOf": [ + # Require at least one property + {"required": [name]} + for name in [ + "username", + "password", + "email", + "enabled", + "lang", + "superuser", + "permissions", + "roles", + "hints", + ] + ], + "additionalProperties": False, + } + auth_data = self.authenticate_user() + if not auth_data: + return + ( + _, + exec_user_crafty_permissions, + _, + superuser, + user, + ) = auth_data + + try: + data = json.loads(self.request.body) + except json.decoder.JSONDecodeError as e: + return self.finish_json( + 400, {"status": "error", "error": "INVALID_JSON", "error_data": str(e)} + ) + + try: + validate(data, user_patch_schema) + except ValidationError as e: + return self.finish_json( + 400, + { + "status": "error", + "error": "INVALID_JSON_SCHEMA", + "error_data": str(e), + }, + ) + + if user_id == "@me": + user_id = user["user_id"] + + if ( + EnumPermissionsCrafty.USER_CONFIG not in exec_user_crafty_permissions + and str(user["user_id"]) != str(user_id) + ): + # If doesn't have perm can't edit other users + return self.finish_json( + 400, + { + "status": "error", + "error": "NOT_AUTHORIZED", + }, + ) + + if data.get("username", None) is not None: + if data["username"].lower() in ["system", ""]: + return self.finish_json( + 400, {"status": "error", "error": "INVALID_USERNAME"} + ) + if self.controller.users.get_id_by_name(data["username"]) is not None: + return self.finish_json( + 400, {"status": "error", "error": "USER_EXISTS"} + ) + + if data.get("superuser", None) is not None: + if str(user["user_id"]) == str(user_id): + # Checks if user is trying to change super user status of self. + # We don't want that. + return self.finish_json( + 400, {"status": "error", "error": "INVALID_SUPERUSER_MODIFY"} + ) + if not superuser: + # The user is not superuser so they can't change the superuser status + data.pop("superuser") + + if data.get("permissions", None) is not None: + if str(user["user_id"]) == str(user_id): + # Checks if user is trying to change permissions of self. + # We don't want that. + return self.finish_json( + 400, {"status": "error", "error": "INVALID_PERMISSIONS_MODIFY"} + ) + if EnumPermissionsCrafty.USER_CONFIG not in exec_user_crafty_permissions: + # Checks if user is trying to change permissions of someone + # else without User Config permission. We don't want that. + return self.finish_json( + 400, {"status": "error", "error": "INVALID_PERMISSIONS_MODIFY"} + ) + + if data.get("roles", None) is not None: + if str(user["user_id"]) == str(user_id): + # Checks if user is trying to change roles of self. + # We don't want that. + return self.finish_json( + 400, {"status": "error", "error": "INVALID_ROLES_MODIFY"} + ) + if EnumPermissionsCrafty.USER_CONFIG not in exec_user_crafty_permissions: + # Checks if user is trying to change roles of someone + # else without User Config permission. We don't want that. + return self.finish_json( + 400, {"status": "error", "error": "INVALID_ROLES_MODIFY"} + ) + + # TODO: make this more efficient + # TODO: add permissions and roles because I forgot + user_obj = HelperUsers.get_user_model(user_id) + + self.controller.management.add_to_audit_log( + user["user_id"], + ( + f"edited user {user_obj.username} (UID: {user_id})" + f"with roles {user_obj.roles}" + ), + server_id=0, + source_ip=self.get_remote_ip(), + ) + + for key in data: + # If we don't validate the input there could be security issues + setattr(user_obj, key, data[key]) + user_obj.save() + + return self.finish_json(200, {"status": "ok"}) diff --git a/app/classes/web/routes/api/users/user/pfp.py b/app/classes/web/routes/api/users/user/pfp.py new file mode 100644 index 00000000..a4f0a480 --- /dev/null +++ b/app/classes/web/routes/api/users/user/pfp.py @@ -0,0 +1,49 @@ +import logging +import libgravatar +import requests +from app.classes.web.base_api_handler import BaseApiHandler + +logger = logging.getLogger(__name__) + + +class ApiUsersUserPfpHandler(BaseApiHandler): + def get(self, user_id): + auth_data = self.authenticate_user() + if not auth_data: + return + + if user_id == "@me": + user = auth_data[4] + else: + user = self.controller.users.get_user_by_id(user_id) + + logger.debug( + f'User {auth_data[4]["user_id"]} is fetching the pfp for user {user_id}' + ) + + # http://en.gravatar.com/site/implement/images/#rating + if self.helper.get_setting("allow_nsfw_profile_pictures"): + rating = "x" + else: + rating = "g" + + # Get grvatar hash for profile pictures + if user["email"] != "default@example.com" or "": + gravatar = libgravatar.Gravatar(libgravatar.sanitize_email(user["email"])) + url = gravatar.get_image( + size=80, + default="404", + force_default=False, + rating=rating, + filetype_extension=False, + use_ssl=True, + ) + try: + requests.head(url).raise_for_status() + except requests.HTTPError as e: + logger.debug("Gravatar profile picture not found", exc_info=e) + else: + self.finish_json(200, {"status": "ok", "data": url}) + return + + self.finish_json(200, {"status": "ok", "data": None}) diff --git a/app/classes/web/routes/api/users/user/public.py b/app/classes/web/routes/api/users/user/public.py new file mode 100644 index 00000000..b67ab61e --- /dev/null +++ b/app/classes/web/routes/api/users/user/public.py @@ -0,0 +1,37 @@ +import logging +from app.classes.models.roles import HelperRoles +from app.classes.models.users import PUBLIC_USER_ATTRS +from app.classes.web.base_api_handler import BaseApiHandler + +logger = logging.getLogger(__name__) + + +class ApiUsersUserPublicHandler(BaseApiHandler): + def get(self, user_id: str): + auth_data = self.authenticate_user() + if not auth_data: + return + ( + _, + _, + _, + _, + user, + ) = auth_data + + if user_id == "@me": + user_id = user["user_id"] + public_user = user + else: + public_user = self.controller.users.get_user_by_id(user_id) + + public_user = {key: public_user.get(key) for key in PUBLIC_USER_ATTRS} + + public_user["roles"] = list( + map(HelperRoles.get_role, public_user.get("roles", set())) + ) + + self.finish_json( + 200, + {"status": "ok", "data": public_user}, + ) diff --git a/app/classes/web/tornado_handler.py b/app/classes/web/tornado_handler.py index 9ebcd5f7..f501c7d1 100644 --- a/app/classes/web/tornado_handler.py +++ b/app/classes/web/tornado_handler.py @@ -13,10 +13,12 @@ import tornado.httpserver from app.classes.shared.console import Console from app.classes.shared.helpers import Helpers +from app.classes.shared.main_controller import Controller from app.classes.web.file_handler import FileHandler from app.classes.web.public_handler import PublicHandler from app.classes.web.panel_handler import PanelHandler from app.classes.web.default_handler import DefaultHandler +from app.classes.web.routes.api.api_handlers import api_handlers from app.classes.web.server_handler import ServerHandler from app.classes.web.ajax_handler import AjaxHandler from app.classes.web.api_handler import ( @@ -42,6 +44,9 @@ logger = logging.getLogger(__name__) class Webserver: + controller: Controller + helper: Helpers + def __init__(self, helper, controller, tasks_manager): self.ioloop = None self.http_server = None @@ -150,7 +155,7 @@ class Webserver: (r"/ws", SocketHandler, handler_args), (r"/upload", UploadHandler, handler_args), (r"/status", StatusHandler, handler_args), - # API Routes + # API Routes V1 (r"/api/v1/stats/servers", ServersStats, handler_args), (r"/api/v1/stats/node", NodeStats, handler_args), (r"/api/v1/server/send_command", SendCommand, handler_args), @@ -161,6 +166,8 @@ class Webserver: (r"/api/v1/list_servers", ListServers, handler_args), (r"/api/v1/users/create_user", CreateUser, handler_args), (r"/api/v1/users/delete_user", DeleteUser, handler_args), + # API Routes V2 + *api_handlers(handler_args), ] app = tornado.web.Application( diff --git a/app/config/config.json b/app/config/config.json index 20921ee7..ea2e1f0a 100644 --- a/app/config/config.json +++ b/app/config/config.json @@ -1,26 +1,27 @@ { - "http_port": 8000, - "https_port": 8443, - "language": "en_EN", - "cookie_expire": 30, - "cookie_secret": "random", - "apikey_secret": "random", - "show_errors": true, - "history_max_age": 7, - "stats_update_frequency": 30, - "delete_default_json": false, - "show_contribute_link": true, - "virtual_terminal_lines": 70, - "max_log_lines": 700, - "max_audit_entries": 300, - "disabled_language_files": [ - "lol_EN.json", - "" - ], - "stream_size_GB": 1, - "keywords": [ - "help", - "chunk" - ], - "allow_nsfw_profile_pictures": false -} \ No newline at end of file + "http_port": 8000, + "https_port": 8443, + "language": "en_EN", + "cookie_expire": 30, + "cookie_secret": "random", + "apikey_secret": "random", + "show_errors": true, + "history_max_age": 7, + "stats_update_frequency": 30, + "delete_default_json": false, + "show_contribute_link": true, + "virtual_terminal_lines": 70, + "max_log_lines": 700, + "max_audit_entries": 300, + "disabled_language_files": [ + "lol_EN.json", + "" + ], + "stream_size_GB": 1, + "keywords": [ + "help", + "chunk" + ], + "allow_nsfw_profile_pictures": false, + "enable_user_self_delete": false +} diff --git a/app/frontend/templates/panel/dashboard.html b/app/frontend/templates/panel/dashboard.html index b36681a9..c51c35f0 100644 --- a/app/frontend/templates/panel/dashboard.html +++ b/app/frontend/templates/panel/dashboard.html @@ -874,4 +874,4 @@ -{% end %} \ No newline at end of file +{% end %} diff --git a/requirements.txt b/requirements.txt index f336addf..d57a8b2f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,3 +17,5 @@ requests==2.26 termcolor==1.1 tornado==6.0 tzlocal==4.0 +jsonschema==4.4.0 +orjson==3.6.7