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 252fdd01..dbd58578 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,9 +171,8 @@ class RolesController:
role = HelperRoles.get_role(role_id)
if role:
- servers_query = PermissionsServers.get_servers_from_role(role_id)
- servers = {s.server_id_id for s in servers_query}
- role["servers"] = servers
+ server_ids = PermissionsServers.get_server_ids_from_role(role_id)
+ role["servers"] = 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 bdbb962c..f9aa2143 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()
@@ -110,7 +148,9 @@ class ServersController:
@staticmethod
def get_authorized_servers_stats_api_key(api_key: ApiKeys):
server_data = []
- authorized_servers = ServersController.get_authorized_servers(api_key.user_id)
+ authorized_servers = ServersController.get_authorized_servers(
+ api_key.user_id # TODO: API key authorized servers?
+ )
for server in authorized_servers:
latest = HelperServerStats.get_latest_server_stats(server.get("server_id"))
diff --git a/app/classes/controllers/users_controller.py b/app/classes/controllers/users_controller.py
index bce58fa9..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,32 +136,38 @@ 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"])
+ elif key == "lang":
+ up_data["lang"] = user_data["lang"]
+ elif key == "hints":
+ up_data["hints"] = user_data["hints"]
elif base_data[key] != user_data[key]:
up_data[key] = user_data[key]
up_data["last_update"] = self.helper.get_time_as_string()
- up_data["lang"] = user_data["lang"]
- up_data["hints"] = user_data["hints"]
logger.debug(f"user: {user_data} +role:{added_roles} -role:{removed_roles}")
for role in added_roles:
HelperUsers.get_or_create(user_id=user_id, role_id=role)
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
@@ -107,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,
@@ -159,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):
@@ -205,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 600cabc9..28de391b 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,
@@ -168,7 +169,12 @@ class PermissionsCrafty:
)
@staticmethod
- def add_server_creation(user_id):
+ def add_server_creation(user_id: int):
+ """Increase the "Server Creation" counter for this user
+
+ Args:
+ user_id (int): The modifiable user's ID
+ """
UserCrafty.update(created_server=UserCrafty.created_server + 1).where(
UserCrafty.user_id == user_id
).execute()
diff --git a/app/classes/models/management.py b/app/classes/models/management.py
index 8c6a49a6..d100ee31 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
@@ -399,7 +404,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)
@@ -411,7 +416,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 4e2325b8..4bd58906 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(
@@ -67,5 +89,5 @@ class HelperRoles:
return Roles.delete().where(Roles.role_id == role_id).execute()
@staticmethod
- def role_id_exists(role_id):
+ def role_id_exists(role_id) -> bool:
return Roles.select().where(Roles.role_id == role_id).count() != 0
diff --git a/app/classes/models/server_permissions.py b/app/classes/models/server_permissions.py
index 7777ab46..afdf428c 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,
@@ -93,17 +93,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 (
@@ -171,9 +183,9 @@ class PermissionsServers:
).execute()
@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 2cc39026..c2c449d7 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,
@@ -105,6 +130,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
# **********************************************************************************
@@ -113,6 +156,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 c2295290..235df1cd 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,7 +260,7 @@ class HelperUsers:
def remove_user(self, user_id):
with self.database.atomic():
UserRoles.delete().where(UserRoles.user_id == user_id).execute()
- Users.delete().where(Users.user_id == user_id).execute()
+ return Users.delete().where(Users.user_id == user_id).execute()
@staticmethod
def set_support_path(user_id, support_path):
@@ -268,11 +304,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):
@@ -281,7 +316,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:
@@ -323,6 +358,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
# **********************************************************************************
@@ -340,8 +379,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/command.py b/app/classes/shared/command.py
index f8fa8b48..2071c645 100644
--- a/app/classes/shared/command.py
+++ b/app/classes/shared/command.py
@@ -3,6 +3,7 @@ import cmd
import time
import threading
import logging
+import getpass
from app.classes.shared.console import Console
from app.classes.shared.import3 import Import3
@@ -11,11 +12,13 @@ logger = logging.getLogger(__name__)
class MainPrompt(cmd.Cmd):
- def __init__(self, helper, tasks_manager, migration_manager):
+ def __init__(self, helper, tasks_manager, migration_manager, main_controller):
super().__init__()
self.helper = helper
self.tasks_manager = tasks_manager
self.migration_manager = migration_manager
+ self.controller = main_controller
+
# overrides the default Prompt
self.prompt = f"Crafty Controller v{self.helper.get_version_string()} > "
@@ -49,6 +52,37 @@ class MainPrompt(cmd.Cmd):
else:
Console.info("Unknown migration command")
+ def do_set_passwd(self, line):
+
+ try:
+ username = str(line).lower()
+ # If no user is found it returns None
+ user_id = self.controller.users.get_id_by_name(username)
+ if not username:
+ Console.error("You must enter a username. Ex: `set_passwd admin'")
+ return False
+ if not user_id:
+ Console.error(f"No user found by the name of {username}")
+ return False
+ except:
+ Console.error(f"User: {line} Not Found")
+ return False
+ new_pass = getpass.getpass(prompt=f"NEW password for: {username} > ")
+ new_pass_conf = getpass.getpass(prompt="Re-enter your password: > ")
+
+ if new_pass != new_pass_conf:
+ Console.error("Passwords do not match. Please try again.")
+ return False
+
+ if len(new_pass) > 512:
+ Console.warning("Passwords must be greater than 6char long and under 512")
+ return False
+
+ if len(new_pass) < 6:
+ Console.warning("Passwords must be greater than 6char long and under 512")
+ return False
+ self.controller.users.update_user(user_id, {"password": new_pass})
+
@staticmethod
def do_threads(_line):
for thread in threading.enumerate():
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 7dbedbdf..c2cf04c4 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 Optional, 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]) -> Server:
+ def get_server_obj(self, server_id: t.Union[str, int]) -> Server:
for server in self.servers_list:
if str(server["server_id"]) == str(server_id):
return server["server_obj"]
@@ -284,7 +284,9 @@ class Controller:
logger.warning(f"Unable to find server object for server id {server_id}")
raise Exception(f"Unable to find server object for server id {server_id}")
- def get_server_obj_optional(self, server_id: Union[str, int]) -> Optional[Server]:
+ def get_server_obj_optional(
+ self, server_id: t.Union[str, int]
+ ) -> t.Optional[Server]:
for server in self.servers_list:
if str(server["server_id"]) == str(server_id):
return server["server_obj"]
@@ -305,6 +307,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 = []
@@ -345,6 +351,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,
@@ -767,6 +944,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(
@@ -780,6 +958,7 @@ class Controller:
server_stop,
server_type,
server_port,
+ server_host,
)
if not Helpers.check_file_exists(
@@ -796,7 +975,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 38132d9f..58e1cc2d 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
@@ -102,6 +105,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 854a6c96..4512cf48 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 0d091aef..c51c35f0 100644
--- a/app/frontend/templates/panel/dashboard.html
+++ b/app/frontend/templates/panel/dashboard.html
@@ -39,12 +39,12 @@
+ title="{% raw translate('dashboard', 'cpuCores', data['lang']) %}: {{ data.get('hosts_data').get('cpu_cores') }}
{% raw translate('dashboard', 'cpuCurFreq', data['lang']) %}: {{ data.get('hosts_data').get('cpu_cur_freq') }}
{% raw translate('dashboard', 'cpuMaxFreq', data['lang']) %}: {{ data.get('hosts_data').get('cpu_max_freq') }}">
{{ translate('dashboard', 'cpuUsage', data['lang']) }}: {{
data.get('hosts_data').get('cpu_usage') }}
+ title="{{ translate('dashboard', 'memUsage', data['lang']) }}: {{ data.get('hosts_data').get('mem_usage') }}"> {{ translate('dashboard', 'memUsage', data['lang']) }}: {{ data.get('hosts_data').get('mem_percent') }}%
@@ -95,8 +95,8 @@ {% if len(data['servers']) > 0 %} {% if data['user_data']['hints'] %} + data-content="{{ translate('dashboard', 'cannotSeeOnMobile2', data['lang']) }}" , + data-placement="top"> {% end %} {% end %}{{ translate('dashboard', 'server', data['lang']) }} | @@ -140,18 +141,18 @@{% if server['user_command_permission'] %} {% if server['stats']['running'] %} - + - + - + @@ -165,20 +166,19 @@ translate('dashboard', 'delay-explained' , data['lang'])}}">{{ translate('dashboard', 'starting', data['lang']) }} {% elif server['stats']['downloading']%} - - {{ translate('serverTerm', 'downloading', + {{ translate('serverTerm', 'downloading', data['lang']) }} {% else %} - + - + - + {% end %} @@ -187,7 +187,7 @@ |
+ title="{{server['stats']['cpu']}}">
+ aria-valuemin="0" aria-valuemax="100">
{{server['stats']['cpu']}}%
|
+ title="{{server['stats']['mem']}}">
+ aria-valuemin="0" aria-valuemax="100">
{{server['stats']['mem_percent']}}% -
@@ -234,7 +234,7 @@
{% if server['stats']['desc'] != 'False' %}
{{
+ style="overflow-wrap: break-word !important; max-width: 85px !important; overflow: scroll;">{{
server['stats']['desc'] }} {% end %} @@ -249,8 +249,7 @@ {{ translate('dashboard', 'online', data['lang']) }} {% elif server['stats']['crashed'] %} - {{ translate('dashboard', - 'crashed', + {{ translate('dashboard', 'crashed', data['lang']) }} {% else %} {{ translate('dashboard', 'offline', @@ -258,10 +257,167 @@ {% end %} |
+ data-players="{{ server['stats']['online']}}" data-max="{{ server['stats']['max'] }}">
---|
{{ translate('dashboard', 'server', data['lang']) }} | +{{ translate('dashboard', 'actions', data['lang']) }} | +{{ translate('dashboard', 'status', data['lang']) }} | ++ |
---|---|---|---|
+ + {{ server['server_data']['server_name'] }} + + | ++ {% if server['user_command_permission'] %} + {% if server['stats']['running'] %} + + + + + + + + + + + + {% elif server['stats']['updating']%} + + {{ translate('serverTerm', 'updating', data['lang']) }} + {% elif server['stats']['waiting_start']%} + + {{ translate('dashboard', 'starting', data['lang']) }} + {% elif server['stats']['downloading']%} + {{ translate('serverTerm', 'downloading', data['lang']) }} + {% else %} + + + + + + + + + + {% end %} + {% end %} + | ++ {% if server['stats']['running'] %} + {{ translate('dashboard', 'online', + data['lang']) }} + {% elif server['stats']['crashed'] %} + {{ translate('dashboard', 'crashed', + data['lang']) }} + {% else %} + {{ translate('dashboard', 'offline', + data['lang']) }} + {% end %} + | ++ + | +
+
+
+
+
+
+
+ {{ translate('dashboard', 'cpuUsage', data['lang']) }}+
+
+
+
+
+ {{server['stats']['cpu']}}%
+
+
+ {{ translate('dashboard', 'memUsage', data['lang']) }}+
+
+
+
+
+ {{server['stats']['mem_percent']}}% -
+
+ {% if server['stats']['mem'] == 0 %}
+ 0 MB
+ {% else %}
+ {{server['stats']['mem']}}
+ {% end %}
+ +
+
+
+
+ {{ translate('dashboard', 'size', data['lang']) }}+
+ {{ server['stats']['world_size'] }}
+
+
+
+ {{ translate('dashboard', 'players', data['lang']) }}+
+ {% if server['stats']['int_ping_results'] %}
+ {{ server['stats']['online'] }} / {{ server['stats']['max'] }} {{ translate('dashboard', 'max',
+ data['lang']) }}
+ + + {% if server['stats']['desc'] != 'False' %} + {{ server['stats']['desc'] }} + {% end %} + + {% if server['stats']['version'] != 'False' %} + {{ server['stats']['version'] }} + {% end %} + {% end %} + |
+
Backing up {{data['backup_stats']['total_files']}} Files
{% end %} @@ -57,63 +59,47 @@