Merge branch 'feature/api-v2' into merge/api-v2

This commit is contained in:
luukas 2022-05-05 03:32:09 +03:00
commit 53459d83dc
39 changed files with 2189 additions and 90 deletions

6
.gitignore vendored
View File

@ -18,8 +18,10 @@ env.bak/
venv.bak/
.idea/
servers/
backups/
/servers/
/backups/
/docker/servers/
/docker/backups/
session.lock
.header
default.json

View File

@ -39,7 +39,7 @@ class CraftyPermsController:
return True
# TODO: Complete if we need a User Addition limit
# return crafty_permissions.can_add_in_crafty(
# user_id, Enum_Permissions_Crafty.User_Config
# user_id, EnumPermissionsCrafty.USER_CONFIG
# )
@staticmethod
@ -47,7 +47,7 @@ class CraftyPermsController:
return True
# TODO: Complete if we need a Role Addition limit
# return crafty_permissions.can_add_in_crafty(
# user_id, Enum_Permissions_Crafty.Roles_Config
# user_id, EnumPermissionsCrafty.ROLES_CONFIG
# )
@staticmethod
@ -66,6 +66,14 @@ class CraftyPermsController:
@staticmethod
def add_server_creation(user_id):
"""Increase the "Server Creation" counter for this use
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

View File

@ -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
@ -33,9 +34,29 @@ 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,
) -> 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 run on, defaults to 25565 (optional)
Returns:
int: The new server's id
Raises:
PeeweeException: If the server already exists
"""
return HelperServers.create_server(
name,
server_uuid,
server_dir,
@ -91,7 +112,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(
@ -102,6 +123,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 HelperServers.get_all_servers_stats()
@ -110,7 +145,7 @@ class ServersController:
def get_authorized_servers_stats_api_key(api_key: ApiKeys):
server_data = []
authorized_servers = ServersController.get_authorized_servers(
api_key.user.user_id
api_key.user.user_id # TODO: API key authorized servers?
)
for server in authorized_servers:

View File

@ -1,5 +1,6 @@
import logging
from typing import Optional
import typing
from app.classes.models.users import HelperUsers
from app.classes.models.crafty_permissions import (
@ -16,6 +17,71 @@ 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: typing.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"},
},
"hints": {"type": "boolean"},
}
# **********************************************************************************
# Users Methods
# **********************************************************************************
@ -23,6 +89,10 @@ class UsersController:
def get_all_users():
return HelperUsers.get_all_users()
@staticmethod
def get_all_user_ids():
return HelperUsers.get_all_user_ids()
@staticmethod
def get_id_by_name(username):
return HelperUsers.get_user_id_by_name(username)
@ -80,16 +150,16 @@ class UsersController:
permissions_mask = user_crafty_data.get("permissions_mask", "000")
if "server_quantity" in user_crafty_data:
limit_server_creation = user_crafty_data["server_quantity"][
EnumPermissionsCrafty.SERVER_CREATION.name
]
limit_server_creation = user_crafty_data["server_quantity"].get(
EnumPermissionsCrafty.SERVER_CREATION.name, 0
)
limit_user_creation = user_crafty_data["server_quantity"][
EnumPermissionsCrafty.USER_CONFIG.name
]
limit_role_creation = user_crafty_data["server_quantity"][
EnumPermissionsCrafty.ROLES_CONFIG.name
]
limit_user_creation = user_crafty_data["server_quantity"].get(
EnumPermissionsCrafty.USER_CONFIG.name, 0
)
limit_role_creation = user_crafty_data["server_quantity"].get(
EnumPermissionsCrafty.ROLES_CONFIG.name, 0
)
else:
limit_server_creation = 0
limit_user_creation = 0
@ -107,6 +177,17 @@ class UsersController:
self.users_helper.update_user(user_id, up_data)
def raw_update_user(
self, user_id: int, up_data: typing.Optional[typing.Dict[str, typing.Any]]
):
"""Directly passes the data to the model helper.
Args:
user_id (int): The id of the user to update.
up_data (typing.Optional[typing.Dict[str, typing.Any]]): Update data.
"""
self.users_helper.update_user(user_id, up_data)
def add_user(
self,
username,
@ -159,7 +240,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):

View File

@ -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")

View File

@ -1,4 +1,5 @@
import logging
import typing
from enum import Enum
from peewee import (
ForeignKeyField,
@ -45,21 +46,24 @@ class PermissionsCrafty:
# **********************************************************************************
@staticmethod
def get_permissions_list():
permissions_list = []
permissions_list: typing.List[EnumPermissionsCrafty] = []
for member in EnumPermissionsCrafty.__members__.items():
permissions_list.append(member[1])
return permissions_list
@staticmethod
def get_permissions(permissions_mask):
permissions_list = []
permissions_list: typing.List[EnumPermissionsCrafty] = []
for member in EnumPermissionsCrafty.__members__.items():
if PermissionsCrafty.has_permission(permissions_mask, member[1]):
permissions_list.append(member[1])
return permissions_list
@staticmethod
def has_permission(permission_mask, permission_tested: EnumPermissionsCrafty):
def has_permission(
permission_mask: typing.Mapping[int, str],
permission_tested: EnumPermissionsCrafty,
):
result = False
if permission_mask[permission_tested.value] == "1":
result = True
@ -188,6 +192,14 @@ class PermissionsCrafty:
@staticmethod
def add_server_creation(user_id):
"""Increase the "Server Creation" counter for this use
Args:
user_id (int): The modifiable user's ID
Returns:
int: The new count of servers created by this user
"""
user_crafty = PermissionsCrafty.get_user_crafty(user_id)
user_crafty.created_server += 1
UserCrafty.save(user_crafty)

View File

@ -191,7 +191,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 +213,7 @@ class HelpersManagement:
AuditLog.source_ip: source_ip,
}
).execute()
# deletes records when they're more than 100
# deletes records when there's more than 300
ordered = AuditLog.select().order_by(+AuditLog.created)
for item in ordered:
# configurable through app/config/config.json
@ -400,7 +400,7 @@ class HelpersManagement:
return dir_list
def add_excluded_backup_dir(self, server_id: int, dir_to_add: str):
dir_list = self.get_excluded_backup_dirs()
dir_list = self.get_excluded_backup_dirs(server_id)
if dir_to_add not in dir_list:
dir_list.append(dir_to_add)
excluded_dirs = ",".join(dir_list)
@ -412,7 +412,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)

View File

@ -1,5 +1,6 @@
from enum import Enum
import logging
import typing
from peewee import (
ForeignKeyField,
CharField,
@ -51,14 +52,14 @@ class PermissionsServers:
@staticmethod
def get_permissions_list():
permissions_list = []
permissions_list: typing.List[EnumPermissionsServer] = []
for member in EnumPermissionsServer.__members__.items():
permissions_list.append(member[1])
return permissions_list
@staticmethod
def get_permissions(permissions_mask):
permissions_list = []
permissions_list: typing.List[EnumPermissionsServer] = []
for member in EnumPermissionsServer.__members__.items():
if PermissionsServers.has_permission(permissions_mask, member[1]):
permissions_list.append(member[1])

View File

@ -94,8 +94,28 @@ class HelperServers:
server_log_file: str,
server_stop: str,
server_type: str,
server_port=25565,
):
server_port: int = 25565,
) -> 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 run on, defaults to 25565 (optional)
Returns:
int: The new server's id
Raises:
PeeweeException: If the server already exists
"""
return Servers.insert(
{
Servers.server_name: name,

View File

@ -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,11 @@ class HelperUsers:
query = Users.select().where(Users.username != "system")
return query
@staticmethod
def get_all_user_ids():
query = Users.select(Users.user_id).where(Users.username != "system")
return query
@staticmethod
def get_user_lang_by_id(user_id):
return Users.get(Users.user_id == user_id).lang
@ -153,7 +167,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 +191,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 +226,7 @@ class HelperUsers:
@staticmethod
def get_super_user_list():
final_users = []
final_users: t.List[int] = []
super_users = Users.select().where(
Users.superuser == True # pylint: disable=singleton-comparison
)
@ -224,8 +238,7 @@ class HelperUsers:
def remove_user(self, user_id):
with self.database.atomic():
UserRoles.delete().where(UserRoles.user_id == user_id).execute()
user = Users.get(Users.user_id == user_id)
return user.delete_instance()
return Users.delete().where(Users.user_id == user_id).execute()
@staticmethod
def set_support_path(user_id, support_path):
@ -284,7 +297,7 @@ class HelperUsers:
).execute()
@staticmethod
def add_user_roles(user: Union[dict, Users]):
def add_user_roles(user: t.Union[dict, Users]):
if isinstance(user, dict):
user_id = user["user_id"]
else:
@ -329,6 +342,10 @@ class HelperUsers:
def remove_roles_from_role_id(role_id):
UserRoles.delete().where(UserRoles.role_id == role_id).execute()
@staticmethod
def get_users_from_role(role_id):
UserRoles.select().where(UserRoles.role_id == role_id).execute()
# **********************************************************************************
# ApiKeys Methods
# **********************************************************************************
@ -346,8 +363,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(
{

View File

@ -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

View File

@ -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

View File

@ -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):

View File

@ -12,6 +12,7 @@ from apscheduler.triggers.cron import CronTrigger
from app.classes.models.management import HelpersManagement
from app.classes.models.users import HelperUsers
from app.classes.shared.console import Console
from app.classes.shared.main_controller import Controller
from app.classes.web.tornado_handler import Webserver
logger = logging.getLogger("apscheduler")
@ -32,6 +33,8 @@ scheduler_intervals = {
class TasksManager:
controller: Controller
def __init__(self, helper, controller):
self.helper = helper
self.controller = controller
@ -101,6 +104,17 @@ class TasksManager:
elif command == "restart_server":
svr.restart_threaded_server(user_id)
elif command == "kill_server":
try:
svr.kill()
time.sleep(5)
svr.cleanup_server_object()
svr.record_server_stats()
except Exception as e:
logger.error(
f"Could not find PID for requested termsig. Full error: {e}"
)
elif command == "backup_server":
svr.backup_server()

View File

@ -0,0 +1,7 @@
from app.classes.web.base_handler import BaseHandler
class BaseApiHandler(BaseHandler):
def check_xsrf_cookie(self) -> None:
# Disable XSRF protection on API routes
pass

View File

@ -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

View File

@ -300,6 +300,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:

View File

@ -0,0 +1,55 @@
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.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/([a-z0-9_]+)", ApiUsersUserIndexHandler, handler_args),
(r"/api/v2/users/(@me)", ApiUsersUserIndexHandler, handler_args),
(r"/api/v2/users/([a-z0-9_]+)/pfp", ApiUsersUserPfpHandler, handler_args),
(r"/api/v2/users/(@me)/pfp", ApiUsersUserPfpHandler, handler_args),
(r"/api/v2/users/([a-z0-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,
),
]

View File

@ -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"})

View File

@ -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"},
)

View File

@ -0,0 +1,2 @@
# nothing here yet
# sometime implement configurable self service account creation?

View File

@ -0,0 +1,628 @@
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,
},
},
}
],
"properties": {
"name": {
"title": "Name",
"type": "string",
"examples": ["My Server"],
},
"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"],
"pattern": "^.*$",
},
"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"],
},
"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"],
"properties": {
"type": {
"title": "Server JAR Type",
"type": "string",
"examples": ["Paper"],
},
"version": {
"title": "Server JAR Version",
"type": "string",
"examples": ["1.18.2"],
},
"mem_min": {
"title": "Minimum JVM memory",
"type": "number",
"examples": [1],
"default": 1,
"exclusiveMinimum": 0,
},
"mem_max": {
"title": "Maximum JVM memory",
"type": "number",
"examples": [2],
"default": 2,
"exclusiveMinimum": 0,
},
},
},
"import_server_create_data": {
"title": "Import server data",
"type": "object",
"required": [
"existing_server_path",
"jarfile",
"mem_min",
"mem_max",
],
"properties": {
"existing_server_path": {
"title": "Server path",
"description": "Absolute path to the old server",
"type": "string",
"examples": ["/var/opt/server"],
},
"jarfile": {
"title": "JAR file",
"description": "The JAR file relative to the previous path",
"type": "string",
"examples": ["paper.jar"],
},
"mem_min": {
"title": "Minimum JVM memory",
"type": "number",
"examples": [1],
"default": 1,
"exclusiveMinimum": 0,
},
"mem_max": {
"title": "Maximum JVM memory",
"type": "number",
"examples": [2],
"default": 2,
"exclusiveMinimum": 0,
},
},
},
"import_zip_create_data": {
"title": "Import ZIP server data",
"type": "object",
"required": [
"zip_path",
"zip_root",
"jarfile",
"mem_min",
"mem_max",
],
"properties": {
"zip_path": {
"title": "ZIP path",
"description": "Absolute path to the ZIP archive",
"type": "string",
"examples": ["/var/opt/server.zip"],
},
"zip_root": {
"title": "Server root directory",
"description": "The server root in the ZIP archive",
"type": "string",
"examples": ["/", "/paper-server/"],
},
"jarfile": {
"title": "JAR file",
"description": "The JAR relative to the configured root",
"type": "string",
"examples": ["paper.jar", "jars/vanilla-1.12.jar"],
},
"mem_min": {
"title": "Minimum JVM memory",
"type": "number",
"examples": [1],
"default": 1,
"exclusiveMinimum": 0,
},
"mem_max": {
"title": "Maximum JVM memory",
"type": "number",
"examples": [2],
"default": 2,
"exclusiveMinimum": 0,
},
},
},
},
"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"],
},
"command": {
"title": "Command",
"type": "string",
"default": "echo foo bar baz",
"examples": ["LD_LIBRARY_PATH=. ./bedrock_server"],
},
},
},
"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"],
},
"zip_root": {
"title": "Server root directory",
"description": "The server root in the ZIP archive",
"type": "string",
"examples": ["/", "/paper-server/"],
},
"command": {
"title": "Command",
"type": "string",
"default": "echo foo bar baz",
"examples": ["LD_LIBRARY_PATH=. ./bedrock_server"],
},
},
},
},
"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",
"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",
"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_exec", "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"],
}
},
},
"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"],
},
"command": {
"title": "Command",
"type": "string",
"default": "echo foo bar baz",
"examples": ["caddy start"],
},
},
},
"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"],
},
"zip_root": {
"title": "Server root directory",
"description": "The server root in the ZIP archive",
"type": "string",
"examples": ["/", "/paper-server/"],
},
"command": {
"title": "Command",
"type": "string",
"default": "echo foo bar baz",
"examples": ["caddy start"],
},
},
},
},
"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_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": ["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),
},
)
# TODO: implement everything
self.controller.management.add_to_audit_log(
user["user_id"],
f"Created server {'1234'} (ID:{123})",
server_id=0,
source_ip=self.get_remote_ip(),
)
self.controller.crafty_perms.add_server_creation(user["user_id"])
self.finish_json(
201,
{"status": "ok", "data": {"server_id": ""}},
)

View File

@ -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)}},
)

View File

@ -0,0 +1,163 @@
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__)
server_patch_schema = {
"type": "object",
"properties": {
"server_name": {"type": "string"},
"path": {"type": "string"},
"backup_path": {"type": "string"},
"executable": {"type": "string"},
"log_path": {"type": "string"},
"execution_command": {"type": "string"},
"auto_start": {"type": "boolean"},
"auto_start_delay": {"type": "integer"},
"crash_detection": {"type": "boolean"},
"stop_command": {"type": "string"},
"executable_update_url": {"type": "string"},
"server_ip": {"type": "string"},
"server_port": {"type": "integer"},
"logs_delete_after": {"type": "integer"},
"type": {"type": "string"},
},
"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)
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)
)
server_data = self.controller.get_server_data(server_id)
server_name = server_data["server_name"]
self.controller.management.add_to_audit_log(
auth_data[4]["user_id"],
f"deleted server {server_id} named {server_name}",
server_id,
self.get_remote_ip(),
)
self.tasks_manager.remove_all_server_tasks(server_id)
self.controller.remove_server(server_id, remove_files)
self.finish_json(
200,
{"status": "ok"},
)

View File

@ -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 Commands 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}<br />")
else:
self.finish_json(200, {"status": "ok", "data": lines})

View File

@ -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"]
},
},
)

View File

@ -0,0 +1,28 @@
import logging
from playhouse.shortcuts import model_to_dict
from app.classes.models.servers import HelperServers
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(
HelperServers.get_latest_server_stats(server_id)[0]
),
},
)

View File

@ -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)),
},
)

View File

@ -0,0 +1,172 @@
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 = [
user.user_id
for user in self.controller.users.get_all_user_ids().execute()
]
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)
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})",
server_id=0,
source_ip=self.get_remote_ip(),
)
self.controller.management.add_to_audit_log(
user["user_id"],
f"edited 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)}},
)

View File

@ -0,0 +1,232 @@
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
):
self.controller.users.remove_user(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.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
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"})

View File

@ -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})

View File

@ -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},
)

View File

@ -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(

View File

@ -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
}
"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
}

View File

@ -702,4 +702,4 @@
</script>
{% end %}
{% end %}

View File

@ -17,3 +17,5 @@ requests==2.26
termcolor==1.1
tornado==6.0
tzlocal==4.0
jsonschema==4.4.0
orjson==3.6.7