diff --git a/CHANGELOG.md b/CHANGELOG.md index 1245e3ff..f4b0a708 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,16 +1,30 @@ # Changelog -## --- [4.1.4] - 2023/TBD +## --- [4.2.0] - 2023/TBD ### New features -TBD +- Finish and Activate Arcadia notification backend ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/621) | [Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/626) | [Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/632)) +- Add initial Webhook Notification (Discord, Mattermost, Slack, Teams) ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/594)) ### Bug fixes - PWA: Removed the custom offline page in favour of browser default ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/607)) - Fix hidden servers appearing visible on public mobile status page ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/612)) - Correctly handle if a server returns a string instead of json data on socket ping ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/614)) +- Bump tornado to resolve #269 ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/623)) +- Bump crypto to resolve #267 & #268 ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/622)) +- Fix select installs failing to start, returning missing python package `packaging` ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/629)) +- Fix public status page not updating #255 ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/615)) +- Fix service worker vulrn and CQ raised by SonarQ ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/631)) +- Fix Backup Restore/Schedules, Backup button function on `remote-comms2` ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/634)) ### Refactor -- Refractor/Replace bleach with nh3 ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/616)) +- Consolidate remaining frontend functions into API V2, and remove ajax internal API ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/585)) +- Replace bleach with nh3 ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/628)) +- Add API route for historical server stats ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/615)) +- Add API route for host stats ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/615)) ### Tweaks - Polish/Enhance display for InApp Documentation ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/613)) - Add get_users command to Crafty's console ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/620)) +- Make files hover cursor pointer ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/627)) +- Use `Jar` class naming for jar refresh to make room for steamCMD naming in the future ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/630)) +- Improve ui visibility of Build Wizard selection tabs ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/633)) +- Add additional logging for server bootstrap & moves unnecessary logging to `debug` for improved log clarity ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/635)) ### Lang TBD

diff --git a/README.md b/README.md index eb3cd642..5d2379c8 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ [![Crafty Logo](app/frontend/static/assets/images/logo_long.svg)](https://craftycontrol.com) -# Crafty Controller 4.1.4 +# Crafty Controller 4.2.0 > Python based Control Panel for your Minecraft Server ## What is Crafty Controller? diff --git a/app/classes/controllers/management_controller.py b/app/classes/controllers/management_controller.py index 0c026183..7085b503 100644 --- a/app/classes/controllers/management_controller.py +++ b/app/classes/controllers/management_controller.py @@ -3,7 +3,7 @@ import queue from prometheus_client import CollectorRegistry, Gauge -from app.classes.models.management import HelpersManagement +from app.classes.models.management import HelpersManagement, HelpersWebhooks from app.classes.models.servers import HelperServers logger = logging.getLogger(__name__) @@ -96,8 +96,8 @@ class ManagementController: # Audit_Log Methods # ********************************************************************************** @staticmethod - def get_actity_log(): - return HelpersManagement.get_actity_log() + def get_activity_log(): + return HelpersManagement.get_activity_log() def add_to_audit_log(self, user_id, log_msg, server_id=None, source_ip=None): return self.management_helper.add_to_audit_log( @@ -223,3 +223,30 @@ class ManagementController: @staticmethod def set_master_server_dir(server_dir): HelpersManagement.set_master_server_dir(server_dir) + + # ********************************************************************************** + # Webhooks Methods + # ********************************************************************************** + @staticmethod + def create_webhook(data): + return HelpersWebhooks.create_webhook(data) + + @staticmethod + def modify_webhook(webhook_id, data): + HelpersWebhooks.modify_webhook(webhook_id, data) + + @staticmethod + def get_webhook_by_id(webhook_id): + return HelpersWebhooks.get_webhook_by_id(webhook_id) + + @staticmethod + def get_webhooks_by_server(server_id, model=False): + return HelpersWebhooks.get_webhooks_by_server(server_id, model) + + @staticmethod + def delete_webhook(webhook_id): + HelpersWebhooks.delete_webhook(webhook_id) + + @staticmethod + def delete_webhook_by_server(server_id): + HelpersWebhooks.delete_webhooks_by_server(server_id) diff --git a/app/classes/controllers/servers_controller.py b/app/classes/controllers/servers_controller.py index ca6c8d22..c0bae7b0 100644 --- a/app/classes/controllers/servers_controller.py +++ b/app/classes/controllers/servers_controller.py @@ -105,9 +105,9 @@ class ServersController(metaclass=Singleton): return ret - def get_history_stats(self, server_id, days): + def get_history_stats(self, server_id, hours): srv = ServersController().get_server_instance_by_id(server_id) - return srv.stats_helper.get_history_stats(server_id, days) + return srv.stats_helper.get_history_stats(server_id, hours) @staticmethod def update_unloaded_server(server_obj): diff --git a/app/classes/controllers/users_controller.py b/app/classes/controllers/users_controller.py index 667e01b4..99147a63 100644 --- a/app/classes/controllers/users_controller.py +++ b/app/classes/controllers/users_controller.py @@ -31,7 +31,7 @@ class UsersController: for permission in PermissionsCrafty.get_permissions_list() ], }, - "quantity": {"type": "number", "minimum": 0}, + "quantity": {"type": "number", "minimum": -1}, "enabled": {"type": "boolean"}, } self.user_jsonschema_props: t.Final = { @@ -46,7 +46,7 @@ class UsersController: "password": { "type": "string", "maxLength": 20, - "minLength": 4, + "minLength": 6, "examples": ["crafty"], "title": "Password", }, @@ -73,6 +73,8 @@ class UsersController: "examples": [False], "title": "Superuser", }, + "manager": {"type": ["integer", "null"]}, + "theme": {"type": "string"}, "permissions": { "type": "array", "items": { @@ -84,7 +86,7 @@ class UsersController: "roles": { "type": "array", "items": { - "type": "string", + "type": "integer", "minLength": 1, }, }, diff --git a/app/classes/minecraft/serverjars.py b/app/classes/minecraft/serverjars.py index faa12a7d..447cf80b 100644 --- a/app/classes/minecraft/serverjars.py +++ b/app/classes/minecraft/serverjars.py @@ -8,6 +8,7 @@ import requests from app.classes.controllers.servers_controller import ServersController from app.classes.models.server_permissions import PermissionsServers +from app.classes.shared.websocket_manager import WebSocketManager logger = logging.getLogger(__name__) @@ -179,9 +180,7 @@ class ServerJars: try: ServersController.set_import(server_id) for user in server_users: - self.helper.websocket_helper.broadcast_user( - user, "send_start_reload", {} - ) + WebSocketManager().broadcast_user(user, "send_start_reload", {}) break except Exception as ex: @@ -206,11 +205,9 @@ class ServerJars: server_users = PermissionsServers.get_server_user_list(server_id) for user in server_users: - self.helper.websocket_helper.broadcast_user( + WebSocketManager().broadcast_user( user, "notification", "Executable download finished" ) time.sleep(3) - self.helper.websocket_helper.broadcast_user( - user, "send_start_reload", {} - ) + WebSocketManager().broadcast_user(user, "send_start_reload", {}) return success diff --git a/app/classes/minecraft/stats.py b/app/classes/minecraft/stats.py index c336612a..a3f85c05 100644 --- a/app/classes/minecraft/stats.py +++ b/app/classes/minecraft/stats.py @@ -226,7 +226,7 @@ class Stats: def get_server_players(self, server_id): server = HelperServers.get_server_data_by_id(server_id) - logger.info(f"Getting players for server {server}") + logger.debug(f"Getting players for server {server['server_name']}") internal_ip = server["server_ip"] server_port = server["server_port"] diff --git a/app/classes/models/management.py b/app/classes/models/management.py index 2c64a8ff..e86e3209 100644 --- a/app/classes/models/management.py +++ b/app/classes/models/management.py @@ -17,6 +17,7 @@ from app.classes.models.users import HelperUsers from app.classes.models.servers import Servers from app.classes.models.server_permissions import PermissionsServers from app.classes.shared.main_models import DatabaseShortcuts +from app.classes.shared.websocket_manager import WebSocketManager logger = logging.getLogger(__name__) @@ -78,11 +79,15 @@ class HostStats(BaseModel): # ********************************************************************************** class Webhooks(BaseModel): id = AutoField() - name = CharField(max_length=64, unique=True, index=True) - method = CharField(default="POST") - url = CharField(unique=True) - event = CharField(default="") - send_data = BooleanField(default=True) + server_id = IntegerField(null=True) + name = CharField(default="Custom Webhook", max_length=64) + url = CharField(default="") + webhook_type = CharField(default="Custom") + bot_name = CharField(default="Crafty Controller") + trigger = CharField(default="server_start,server_stop") + body = CharField(default="") + color = CharField(default="#005cd1") + enabled = BooleanField(default=True) class Meta: table_name = "webhooks" @@ -145,7 +150,7 @@ class HelpersManagement: # Audit_Log Methods # ********************************************************************************** @staticmethod - def get_actity_log(): + def get_activity_log(): query = AuditLog.select() return DatabaseShortcuts.return_db_rows(query) @@ -158,9 +163,7 @@ class HelpersManagement: server_users = PermissionsServers.get_server_user_list(server_id) for user in server_users: try: - self.helper.websocket_helper.broadcast_user( - user, "notification", audit_msg - ) + WebSocketManager().broadcast_user(user, "notification", audit_msg) except Exception as e: logger.error(f"Error broadcasting to user {user} - {e}") @@ -502,3 +505,82 @@ class HelpersManagement: f"Not removing {dir_to_del} from excluded directories - " f"not in the excluded directory list for server ID {server_id}" ) + + +# ********************************************************************************** +# Webhooks Class +# ********************************************************************************** +class HelpersWebhooks: + def __init__(self, database): + self.database = database + + @staticmethod + def create_webhook(create_data) -> int: + """Create a webhook in the database + + Args: + server_id: ID of a server this webhook will be married to + name: The name of the webhook + url: URL to the webhook + webhook_type: The provider this webhook will be sent to + bot name: The name that will appear when the webhook is sent + triggers: Server actions that will trigger this webhook + body: The message body of the webhook + enabled: Should Crafty trigger the webhook + + Returns: + int: The new webhooks's id + + Raises: + PeeweeException: If the webhook already exists + """ + return Webhooks.insert( + { + Webhooks.server_id: create_data["server_id"], + Webhooks.name: create_data["name"], + Webhooks.webhook_type: create_data["webhook_type"], + Webhooks.url: create_data["url"], + Webhooks.bot_name: create_data["bot_name"], + Webhooks.body: create_data["body"], + Webhooks.color: create_data["color"], + Webhooks.trigger: create_data["trigger"], + Webhooks.enabled: create_data["enabled"], + } + ).execute() + + @staticmethod + def modify_webhook(webhook_id, updata): + Webhooks.update(updata).where(Webhooks.id == webhook_id).execute() + + @staticmethod + def get_webhook_by_id(webhook_id): + return model_to_dict(Webhooks.get(Webhooks.id == webhook_id)) + + @staticmethod + def get_webhooks_by_server(server_id, model): + if not model: + data = {} + for webhook in ( + Webhooks.select().where(Webhooks.server_id == server_id).execute() + ): + data[str(webhook.id)] = { + "webhook_type": webhook.webhook_type, + "name": webhook.name, + "url": webhook.url, + "bot_name": webhook.bot_name, + "trigger": webhook.trigger, + "body": webhook.body, + "color": webhook.color, + "enabled": webhook.enabled, + } + else: + data = Webhooks.select().where(Webhooks.server_id == server_id).execute() + return data + + @staticmethod + def delete_webhook(webhook_id): + Webhooks.delete().where(Webhooks.id == webhook_id).execute() + + @staticmethod + def delete_webhooks_by_server(server_id): + Webhooks.delete().where(Webhooks.server_id == server_id).execute() diff --git a/app/classes/models/server_stats.py b/app/classes/models/server_stats.py index 958dccb2..8473ed12 100644 --- a/app/classes/models/server_stats.py +++ b/app/classes/models/server_stats.py @@ -51,6 +51,7 @@ class ServerStats(Model): max = IntegerField(default=0) players = CharField(default="") desc = CharField(default="Unable to Connect") + icon = CharField(default="") version = CharField(default="") updating = BooleanField(default=False) waiting_start = BooleanField(default=False) @@ -142,16 +143,20 @@ class HelperServerStats: self.database.close() return server_data - def get_history_stats(self, server_id, num_days): + def get_history_stats(self, server_id, num_hours): self.database.connect(reuse_if_open=True) - max_age = datetime.datetime.now() - timedelta(days=num_days) - server_stats = ( + max_age = datetime.datetime.now() - timedelta(hours=num_hours) + query_stats = ( ServerStats.select() .where(ServerStats.created > max_age) .where(ServerStats.server_id == server_id) + # .order_by(ServerStats.created.desc()) .execute(self.database) ) - self.database.connect(reuse_if_open=True) + server_stats = [] + for stat in query_stats: + server_stats.append(DatabaseShortcuts.get_data_obj(stat)) + self.database.close() return server_stats def insert_server_stats(self, server_stats): @@ -180,6 +185,7 @@ class HelperServerStats: ServerStats.max: server_stats.get("max", False), ServerStats.players: server_stats.get("players", False), ServerStats.desc: server_stats.get("desc", False), + ServerStats.icon: server_stats.get("icon", None), ServerStats.version: server_stats.get("version", False), } ).execute(self.database) diff --git a/app/classes/models/users.py b/app/classes/models/users.py index b0612017..ccd8f1b0 100644 --- a/app/classes/models/users.py +++ b/app/classes/models/users.py @@ -45,6 +45,7 @@ class Users(BaseModel): manager = IntegerField(default=None, null=True) pfp = CharField(default="/static/assets/images/faces-clipart/pic-3.png") theme = CharField(default="default") + cleared_notifs = CharField(default="default") class Meta: table_name = "users" @@ -171,6 +172,7 @@ class HelperUsers: "roles": [], "servers": [], "support_logs": "", + "cleared_notifs": "", } user = model_to_dict(Users.get(Users.user_id == user_id)) diff --git a/app/classes/shared/command.py b/app/classes/shared/command.py index cebe76b7..155fe083 100644 --- a/app/classes/shared/command.py +++ b/app/classes/shared/command.py @@ -11,6 +11,7 @@ from app.classes.shared.helpers import Helpers from app.classes.shared.tasks import TasksManager from app.classes.shared.migration import MigrationManager from app.classes.shared.main_controller import Controller +from app.classes.shared.websocket_manager import WebSocketManager logger = logging.getLogger(__name__) @@ -118,7 +119,7 @@ class MainPrompt(cmd.Cmd): Console.info( "Stopping all server daemons / threads - This may take a few seconds" ) - self.helper.websocket_helper.disconnect_all() + WebSocketManager().disconnect_all() Console.info("Waiting for main thread to stop") while True: if self.tasks_manager.get_main_thread_run_status(): diff --git a/app/classes/shared/file_helpers.py b/app/classes/shared/file_helpers.py index 4005e965..cc09dc4f 100644 --- a/app/classes/shared/file_helpers.py +++ b/app/classes/shared/file_helpers.py @@ -8,6 +8,7 @@ from zipfile import ZipFile, ZIP_DEFLATED from app.classes.shared.helpers import Helpers from app.classes.shared.console import Console +from app.classes.shared.websocket_manager import WebSocketManager logger = logging.getLogger(__name__) @@ -149,7 +150,7 @@ class FileHelpers: "percent": 0, "total_files": self.helper.human_readable_file_size(dir_bytes), } - self.helper.websocket_helper.broadcast_page_params( + WebSocketManager().broadcast_page_params( "/panel/server_detail", {"id": str(server_id)}, "backup_status", @@ -194,7 +195,7 @@ class FileHelpers: "percent": percent, "total_files": self.helper.human_readable_file_size(dir_bytes), } - self.helper.websocket_helper.broadcast_page_params( + WebSocketManager().broadcast_page_params( "/panel/server_detail", {"id": str(server_id)}, "backup_status", @@ -215,7 +216,7 @@ class FileHelpers: "percent": 0, "total_files": self.helper.human_readable_file_size(dir_bytes), } - self.helper.websocket_helper.broadcast_page_params( + WebSocketManager().broadcast_page_params( "/panel/server_detail", {"id": str(server_id)}, "backup_status", @@ -274,7 +275,7 @@ class FileHelpers: "total_files": self.helper.human_readable_file_size(dir_bytes), } # send status results to page. - self.helper.websocket_helper.broadcast_page_params( + WebSocketManager().broadcast_page_params( "/panel/server_detail", {"id": str(server_id)}, "backup_status", @@ -325,3 +326,12 @@ class FileHelpers: else: return "false" return + + def unzip_server(self, zip_path, user_id): + if Helpers.check_file_perms(zip_path): + temp_dir = tempfile.mkdtemp() + with zipfile.ZipFile(zip_path, "r") as zip_ref: + # extracts archive to temp directory + zip_ref.extractall(temp_dir) + if user_id: + return temp_dir diff --git a/app/classes/shared/helpers.py b/app/classes/shared/helpers.py index 489115ae..ba9c5a28 100644 --- a/app/classes/shared/helpers.py +++ b/app/classes/shared/helpers.py @@ -29,7 +29,6 @@ from app.classes.shared.null_writer import NullWriter from app.classes.shared.console import Console from app.classes.shared.installer import installer from app.classes.shared.translation import Translation -from app.classes.web.websocket_helper import WebSocketHelper with redirect_stderr(NullWriter()): import psutil @@ -78,7 +77,6 @@ class Helpers: self.passhasher = PasswordHasher() self.exiting = False - self.websocket_helper = WebSocketHelper(self) self.translation = Translation(self) self.update_available = False self.ignored_names = ["crafty_managed.txt", "db_stats"] @@ -579,20 +577,16 @@ class Helpers: return version_data - @staticmethod - def get_announcements(): - data = ( - '[{"id":"1","date":"Unknown",' - '"title":"Error getting Announcements",' - '"desc":"Error getting Announcements","link":""}]' - ) - + def get_announcements(self): + data = [] try: - response = requests.get("https://craftycontrol.com/notify.json", timeout=2) + response = requests.get("https://craftycontrol.com/notify", timeout=2) data = json.loads(response.content) except Exception as e: logger.error(f"Failed to fetch notifications with error: {e}") + if self.update_available: + data.append(self.update_available) return data def get_version_string(self): @@ -1092,87 +1086,6 @@ class Helpers: return data - def generate_tree(self, folder, output=""): - dir_list = [] - unsorted_files = [] - file_list = os.listdir(folder) - for item in file_list: - if os.path.isdir(os.path.join(folder, item)): - dir_list.append(item) - elif str(item) != self.ignored_names: - unsorted_files.append(item) - file_list = sorted(dir_list, key=str.casefold) + sorted( - unsorted_files, key=str.casefold - ) - for raw_filename in file_list: - filename = html.escape(raw_filename) - rel = os.path.join(folder, raw_filename) - dpath = os.path.join(folder, filename) - if os.path.isdir(rel): - if filename not in self.ignored_names: - output += f"""
  • - \n
    - - - - {filename} - -
  • - \n""" - else: - if filename not in self.ignored_names: - output += f"""
  • - {filename}
  • """ - return output - - def generate_dir(self, folder, output=""): - dir_list = [] - unsorted_files = [] - file_list = os.listdir(folder) - for item in file_list: - if os.path.isdir(os.path.join(folder, item)): - dir_list.append(item) - elif str(item) != self.ignored_names: - unsorted_files.append(item) - file_list = sorted(dir_list, key=str.casefold) + sorted( - unsorted_files, key=str.casefold - ) - output += f"""\n" - return output - @staticmethod def generate_zip_tree(folder, output=""): file_list = os.listdir(folder) @@ -1216,23 +1129,6 @@ class Helpers:
  • """ return output - def unzip_server(self, zip_path, user_id): - if Helpers.check_file_perms(zip_path): - temp_dir = tempfile.mkdtemp() - with zipfile.ZipFile(zip_path, "r") as zip_ref: - # extracts archive to temp directory - zip_ref.extractall(temp_dir) - if user_id: - self.websocket_helper.broadcast_user( - user_id, "send_temp_path", {"path": temp_dir} - ) - - def backup_select(self, path, user_id): - if user_id: - self.websocket_helper.broadcast_user( - user_id, "send_temp_path", {"path": path} - ) - @staticmethod def unzip_backup_archive(backup_path, zip_name): zip_path = os.path.join(backup_path, zip_name) diff --git a/app/classes/shared/import_helper.py b/app/classes/shared/import_helper.py index e3762aad..1acf7a03 100644 --- a/app/classes/shared/import_helper.py +++ b/app/classes/shared/import_helper.py @@ -9,6 +9,7 @@ from app.classes.controllers.server_perms_controller import PermissionsServers from app.classes.controllers.servers_controller import ServersController from app.classes.shared.helpers import Helpers from app.classes.shared.file_helpers import FileHelpers +from app.classes.shared.websocket_manager import WebSocketManager logger = logging.getLogger(__name__) @@ -64,7 +65,7 @@ class ImportHelpers: ServersController.finish_import(new_id) server_users = PermissionsServers.get_server_user_list(new_id) for user in server_users: - self.helper.websocket_helper.broadcast_user(user, "send_start_reload", {}) + WebSocketManager().broadcast_user(user, "send_start_reload", {}) def import_java_zip_server(self, temp_dir, new_server_dir, port, new_id): import_thread = threading.Thread( @@ -108,7 +109,7 @@ class ImportHelpers: server_users = PermissionsServers.get_server_user_list(new_id) ServersController.finish_import(new_id) for user in server_users: - self.helper.websocket_helper.broadcast_user(user, "send_start_reload", {}) + WebSocketManager().broadcast_user(user, "send_start_reload", {}) # deletes temp dir FileHelpers.del_dirs(temp_dir) @@ -162,7 +163,7 @@ class ImportHelpers: ServersController.finish_import(new_id) server_users = PermissionsServers.get_server_user_list(new_id) for user in server_users: - self.helper.websocket_helper.broadcast_user(user, "send_start_reload", {}) + WebSocketManager().broadcast_user(user, "send_start_reload", {}) def import_bedrock_zip_server( self, temp_dir, new_server_dir, full_jar_path, port, new_id @@ -209,7 +210,7 @@ class ImportHelpers: ServersController.finish_import(new_id) server_users = PermissionsServers.get_server_user_list(new_id) for user in server_users: - self.helper.websocket_helper.broadcast_user(user, "send_start_reload", {}) + WebSocketManager().broadcast_user(user, "send_start_reload", {}) if os.name != "nt": if Helpers.check_file_exists(full_jar_path): os.chmod(full_jar_path, 0o2760) @@ -253,4 +254,4 @@ class ImportHelpers: ServersController.finish_import(new_id) server_users = PermissionsServers.get_server_user_list(new_id) for user in server_users: - self.helper.websocket_helper.broadcast_user(user, "send_start_reload", {}) + WebSocketManager().broadcast_user(user, "send_start_reload", {}) diff --git a/app/classes/shared/main_controller.py b/app/classes/shared/main_controller.py index 95872884..541e45ad 100644 --- a/app/classes/shared/main_controller.py +++ b/app/classes/shared/main_controller.py @@ -5,6 +5,7 @@ from datetime import datetime import platform import shutil import time +import json import logging import threading from peewee import DoesNotExist @@ -32,6 +33,7 @@ from app.classes.shared.helpers import Helpers from app.classes.shared.file_helpers import FileHelpers from app.classes.shared.import_helper import ImportHelpers from app.classes.minecraft.serverjars import ServerJars +from app.classes.shared.websocket_manager import WebSocketManager logger = logging.getLogger(__name__) @@ -84,6 +86,17 @@ class Controller: def set_project_root(self, root_dir): self.project_root = root_dir + def set_config_json(self, data): + current_config = self.helper.get_all_settings() + for key in current_config: + if key in data: + current_config[key] = data[key] + keys = list(current_config.keys()) + keys.sort() + sorted_data = {i: current_config[i] for i in keys} + with open(self.helper.settings_file, "w", encoding="utf-8") as f: + json.dump(sorted_data, f, indent=4) + def package_support_logs(self, exec_user): if exec_user["preparing"]: return @@ -101,7 +114,7 @@ class Controller: self.del_support_file(exec_user["support_logs"]) # pausing so on screen notifications can run for user time.sleep(7) - self.helper.websocket_helper.broadcast_user( + WebSocketManager().broadcast_user( exec_user["user_id"], "notification", "Preparing your support logs" ) self.helper.ensure_dir_exists( @@ -197,17 +210,15 @@ class Controller: ) as f: f.write(sys_info_string) FileHelpers.make_compressed_archive(temp_zip_storage, temp_dir, sys_info_string) - if len(self.helper.websocket_helper.clients) > 0: - self.helper.websocket_helper.broadcast_user( + if len(WebSocketManager().clients) > 0: + WebSocketManager().broadcast_user( exec_user["user_id"], "support_status_update", Helpers.calc_percent(temp_dir, temp_zip_storage + ".zip"), ) temp_zip_storage += ".zip" - self.helper.websocket_helper.broadcast_user( - exec_user["user_id"], "send_logs_bootbox", {} - ) + WebSocketManager().broadcast_user(exec_user["user_id"], "send_logs_bootbox", {}) self.users.set_support_path(exec_user["user_id"], temp_zip_storage) @@ -240,8 +251,8 @@ class Controller: results = Helpers.calc_percent(source_path, dest_path) self.log_stats = results - if len(self.helper.websocket_helper.clients) > 0: - self.helper.websocket_helper.broadcast_user( + if len(WebSocketManager().clients) > 0: + WebSocketManager().broadcast_user( exec_user["user_id"], "support_status_update", results ) @@ -300,15 +311,6 @@ class Controller: 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) @@ -336,22 +338,25 @@ class Controller: server_file = f"{create_data['type']}-{create_data['version']}.jar" # 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") - ) + if "agree_to_eula" in create_data: + 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"]) server_file = create_data["jarfile"] elif root_create_data["create_type"] == "import_zip": # TODO: Copy files from the zip file to the new server directory server_file = create_data["jarfile"] raise NotImplementedError("Not yet implemented") - _create_server_properties_if_needed( - create_data["server_properties_port"], - ) + # self.import_helper.import_java_zip_server() + if data["create_type"] == "minecraft_java": + _create_server_properties_if_needed( + create_data["server_properties_port"], + ) min_mem = create_data["mem_min"] max_mem = create_data["mem_max"] @@ -364,30 +369,72 @@ class Controller: def _wrap_jar_if_windows(): return f'"{server_file}"' if Helpers.is_os_windows() else server_file - 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" - ) + if root_create_data["create_type"] == "download_jar": + if Helpers.is_os_windows(): + # Let's check for and setup for install server commands + if create_data["type"] == "forge": + server_command = ( + f"java -Xms{Helpers.float_to_string(min_mem)}M " + f"-Xmx{Helpers.float_to_string(max_mem)}M " + f'-jar "{server_file}" --installServer' + ) + else: + server_command = ( + f"java -Xms{Helpers.float_to_string(min_mem)}M " + f"-Xmx{Helpers.float_to_string(max_mem)}M " + f'-jar "{server_file}" nogui' + ) + else: + if create_data["type"] == "forge": + server_command = ( + f"java -Xms{Helpers.float_to_string(min_mem)}M " + f"-Xmx{Helpers.float_to_string(max_mem)}M " + f"-jar {server_file} --installServer" + ) + else: + server_command = ( + f"java -Xms{Helpers.float_to_string(min_mem)}M " + f"-Xmx{Helpers.float_to_string(max_mem)}M " + f"-jar {server_file} nogui" + ) + else: + 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}") + if Helpers.is_os_windows(): + server_command = ( + f'"{os.path.join(new_server_path, create_data["executable"])}"' + ) + else: + server_command = f"./{create_data['executable']}" + logger.debug("command: " + server_command) + server_file = create_data["executable"] elif root_create_data["create_type"] == "import_zip": # TODO: Copy files from the zip file to the new server directory raise NotImplementedError("Not yet implemented") + else: + server_file = "bedrock_server" + if Helpers.is_os_windows(): + # if this is windows we will override the linux bedrock server name. + server_file = "bedrock_server.exe" + full_jar_path = os.path.join(new_server_path, server_file) + + if self.helper.is_os_windows(): + server_command = f'"{full_jar_path}"' + else: + server_command = f"./{server_file}" _create_server_properties_if_needed(0, True) - server_command = create_data["command"] - server_file = ( - "./bedrock_server" # HACK: This is a hack to make the server start - ) + server_command = create_data.get("command", server_command) elif data["create_type"] == "custom": # TODO: working_directory, executable_update if root_create_data["create_type"] == "raw_exec": @@ -451,131 +498,85 @@ class Controller: server_host=monitoring_host, server_type=monitoring_type, ) - - if ( - data["create_type"] == "minecraft_java" - and root_create_data["create_type"] == "download_jar" - ): - # modded update urls from server jars will only update the installer - if create_data["category"] != "modded": - server_obj = self.servers.get_server_obj(new_server_id) - url = ( - f"https://serverjars.com/api/fetchJar/{create_data['category']}" - f"/{create_data['type']}/{create_data['version']}" + if data["create_type"] == "minecraft_java": + if root_create_data["create_type"] == "download_jar": + # modded update urls from server jars will only update the installer + if create_data["category"] != "modded": + server_obj = self.servers.get_server_obj(new_server_id) + url = ( + f"https://serverjars.com/api/fetchJar/{create_data['category']}" + f"/{create_data['type']}/{create_data['version']}" + ) + server_obj.executable_update_url = url + self.servers.update_server(server_obj) + self.server_jars.download_jar( + create_data["category"], + create_data["type"], + create_data["version"], + full_jar_path, + new_server_id, ) - server_obj.executable_update_url = url - self.servers.update_server(server_obj) - self.server_jars.download_jar( - create_data["category"], - create_data["type"], - create_data["version"], - full_jar_path, - new_server_id, - ) + elif root_create_data["create_type"] == "import_server": + ServersController.set_import(new_server_id) + self.import_helper.import_jar_server( + create_data["existing_server_path"], + new_server_path, + monitoring_port, + new_server_id, + ) + elif root_create_data["create_type"] == "import_zip": + ServersController.set_import(new_server_id) + + elif data["create_type"] == "minecraft_bedrock": + if root_create_data["create_type"] == "download_exe": + ServersController.set_import(new_server_id) + self.import_helper.download_bedrock_server( + new_server_path, new_server_id + ) + elif root_create_data["create_type"] == "import_server": + ServersController.set_import(new_server_id) + full_exe_path = os.path.join(new_server_path, create_data["executable"]) + self.import_helper.import_bedrock_server( + create_data["existing_server_path"], + new_server_path, + monitoring_port, + full_exe_path, + new_server_id, + ) + elif root_create_data["create_type"] == "import_zip": + ServersController.set_import(new_server_id) + full_exe_path = os.path.join(new_server_path, create_data["executable"]) + self.import_helper.import_bedrock_zip_server( + create_data["zip_path"], + new_server_path, + os.path.join(create_data["zip_root"], create_data["executable"]), + monitoring_port, + new_server_id, + ) + + exec_user = self.users.get_user_by_id(int(user_id)) + captured_roles = data.get("roles", []) + # These lines create a new Role for the Server with full permissions + # and add the user to it if he's not a superuser + if len(captured_roles) == 0: + if not exec_user["superuser"]: + new_server_uuid = self.servers.get_server_data_by_id(new_server_id).get( + "server_uuid" + ) + role_id = self.roles.add_role( + f"Creator of Server with uuid={new_server_uuid}", + exec_user["user_id"], + ) + self.server_perms.add_role_server(new_server_id, role_id, "11111111") + self.users.add_role_to_user(exec_user["user_id"], role_id) + + else: + for role in captured_roles: + role_id = role + self.server_perms.add_role_server(new_server_id, role_id, "11111111") return new_server_id, server_fs_uuid - def create_jar_server( - self, - jar: str, - server: str, - version: str, - name: str, - min_mem: int, - max_mem: int, - port: int, - user_id: int, - ): - server_id = Helpers.create_uuid() - server_dir = os.path.join(self.helper.servers_dir, server_id) - backup_path = os.path.join(self.helper.backup_path, server_id) - if Helpers.is_os_windows(): - server_dir = Helpers.wtol_path(server_dir) - backup_path = Helpers.wtol_path(backup_path) - server_dir.replace(" ", "^ ") - backup_path.replace(" ", "^ ") - - server_file = f"{server}-{version}.jar" - - # make the dir - perhaps a UUID? - Helpers.ensure_dir_exists(server_dir) - Helpers.ensure_dir_exists(backup_path) - - try: - # do a eula.txt - with open( - os.path.join(server_dir, "eula.txt"), "w", encoding="utf-8" - ) as file: - file.write("eula=false") - file.close() - - # setup server.properties with the port - with open( - os.path.join(server_dir, "server.properties"), "w", encoding="utf-8" - ) as file: - file.write(f"server-port={port}") - file.close() - - except Exception as e: - logger.error(f"Unable to create required server files due to :{e}") - return False - - if Helpers.is_os_windows(): - # Let's check for and setup for install server commands - if server == "forge": - server_command = ( - f"java -Xms{Helpers.float_to_string(min_mem)}M " - f"-Xmx{Helpers.float_to_string(max_mem)}M " - f'-jar "{server_file}" --installServer' - ) - else: - server_command = ( - f"java -Xms{Helpers.float_to_string(min_mem)}M " - f"-Xmx{Helpers.float_to_string(max_mem)}M " - f'-jar "{server_file}" nogui' - ) - else: - if server == "forge": - server_command = ( - f"java -Xms{Helpers.float_to_string(min_mem)}M " - f"-Xmx{Helpers.float_to_string(max_mem)}M " - f"-jar {server_file} --installServer" - ) - else: - server_command = ( - f"java -Xms{Helpers.float_to_string(min_mem)}M " - f"-Xmx{Helpers.float_to_string(max_mem)}M " - f"-jar {server_file} nogui" - ) - server_log_file = "./logs/latest.log" - server_stop = "stop" - - new_id = self.register_server( - name, - server_id, - server_dir, - backup_path, - server_command, - server_file, - server_log_file, - server_stop, - port, - user_id, - server_type="minecraft-java", - ) - # modded update urls from server jars will only update the installer - if jar != "modded": - server_obj = self.servers.get_server_obj(new_id) - url = f"https://serverjars.com/api/fetchJar/{jar}/{server}/{version}" - server_obj.executable_update_url = url - self.servers.update_server(server_obj) - # download the jar - self.server_jars.download_jar( - jar, server, version, os.path.join(server_dir, server_file), new_id - ) - - return new_id - @staticmethod def verify_jar_server(server_path: str, server_jar: str): server_path = Helpers.get_os_understandable_path(server_path) @@ -593,64 +594,7 @@ class Controller: return False return True - def import_jar_server( - self, - server_name: str, - server_path: str, - server_jar: str, - min_mem: int, - max_mem: int, - port: int, - user_id: int, - ): - server_id = Helpers.create_uuid() - new_server_dir = os.path.join(self.helper.servers_dir, server_id) - backup_path = os.path.join(self.helper.backup_path, server_id) - if Helpers.is_os_windows(): - new_server_dir = Helpers.wtol_path(new_server_dir) - backup_path = Helpers.wtol_path(backup_path) - new_server_dir.replace(" ", "^ ") - backup_path.replace(" ", "^ ") - - Helpers.ensure_dir_exists(new_server_dir) - Helpers.ensure_dir_exists(backup_path) - server_path = Helpers.get_os_understandable_path(server_path) - - full_jar_path = os.path.join(new_server_dir, server_jar) - - if Helpers.is_os_windows(): - server_command = ( - f"java -Xms{Helpers.float_to_string(min_mem)}M " - f"-Xmx{Helpers.float_to_string(max_mem)}M " - f'-jar "{full_jar_path}" nogui' - ) - else: - server_command = ( - f"java -Xms{Helpers.float_to_string(min_mem)}M " - f"-Xmx{Helpers.float_to_string(max_mem)}M " - f"-jar {full_jar_path} nogui" - ) - server_log_file = "./logs/latest.log" - server_stop = "stop" - - new_id = self.register_server( - server_name, - server_id, - new_server_dir, - backup_path, - server_command, - server_jar, - server_log_file, - server_stop, - port, - user_id, - server_type="minecraft-java", - ) - ServersController.set_import(new_id) - self.import_helper.import_jar_server(server_path, new_server_dir, port, new_id) - return new_id - - def import_zip_server( + def restore_java_zip_server( self, server_name: str, zip_path: str, @@ -807,7 +751,7 @@ class Controller: self.import_helper.download_bedrock_server(new_server_dir, new_id) return new_id - def import_bedrock_zip_server( + def restore_bedrock_zip_server( self, server_name: str, zip_path: str, @@ -952,6 +896,7 @@ class Controller: srv_obj = server["server_obj"] srv_obj.server_scheduler.shutdown() + srv_obj.dir_scheduler.shutdown() running = srv_obj.check_running() if running: @@ -1025,7 +970,7 @@ class Controller: def t_update_master_server_dir(self, new_server_path, user_id): new_server_path = self.helper.wtol_path(new_server_path) new_server_path = os.path.join(new_server_path, "servers") - self.helper.websocket_helper.broadcast_page( + WebSocketManager().broadcast_page( "/panel/panel_config", "move_status", "Checking dir" ) current_master = self.helper.wtol_path( @@ -1035,7 +980,7 @@ class Controller: logger.info( "Admin tried to change server dir to current server dir. Canceling..." ) - self.helper.websocket_helper.broadcast_page( + WebSocketManager().broadcast_page( "/panel/panel_config", "move_status", "done", @@ -1046,18 +991,18 @@ class Controller: "Admin tried to change server dir to be inside a sub directory of the" " current server dir. This will result in a copy loop." ) - self.helper.websocket_helper.broadcast_page( + WebSocketManager().broadcast_page( "/panel/panel_config", "move_status", "done", ) return - self.helper.websocket_helper.broadcast_page( + WebSocketManager().broadcast_page( "/panel/panel_config", "move_status", "Checking permissions" ) if not self.helper.ensure_dir_exists(new_server_path): - self.helper.websocket_helper.broadcast_user( + WebSocketManager().broadcast_user( user_id, "send_start_error", { @@ -1066,6 +1011,8 @@ class Controller: "the new directory." }, ) + self.helper.dir_migration = False + return # set the cached serve dir self.helper.servers_dir = new_server_path @@ -1079,7 +1026,7 @@ class Controller: new_server_path, server.get("server_uuid") ) if os.path.isdir(server_path): - self.helper.websocket_helper.broadcast_page( + WebSocketManager().broadcast_page( "/panel/panel_config", "move_status", f"Moving {server.get('server_name')}", @@ -1120,7 +1067,7 @@ class Controller: self.servers.update_unloaded_server(server_obj) self.servers.init_all_servers() self.helper.dir_migration = False - self.helper.websocket_helper.broadcast_page( + WebSocketManager().broadcast_page( "/panel/panel_config", "move_status", "done", diff --git a/app/classes/shared/server.py b/app/classes/shared/server.py index e5c123af..054ca041 100644 --- a/app/classes/shared/server.py +++ b/app/classes/shared/server.py @@ -19,7 +19,7 @@ from zoneinfo import ZoneInfo from tzlocal import get_localzone from tzlocal.utils import ZoneInfoNotFoundError from apscheduler.schedulers.background import BackgroundScheduler -from apscheduler.jobstores.base import JobLookupError +from apscheduler.jobstores.base import JobLookupError, ConflictingIdError # OpenMetrics/Prometheus Imports from prometheus_client import CollectorRegistry, Gauge, Info @@ -28,13 +28,15 @@ from app.classes.minecraft.stats import Stats from app.classes.minecraft.mc_ping import ping, ping_bedrock from app.classes.models.servers import HelperServers, Servers from app.classes.models.server_stats import HelperServerStats -from app.classes.models.management import HelpersManagement +from app.classes.models.management import HelpersManagement, HelpersWebhooks from app.classes.models.users import HelperUsers from app.classes.models.server_permissions import PermissionsServers from app.classes.shared.console import Console from app.classes.shared.helpers import Helpers from app.classes.shared.file_helpers import FileHelpers from app.classes.shared.null_writer import NullWriter +from app.classes.shared.websocket_manager import WebSocketManager +from app.classes.web.webhooks.webhook_factory import WebhookFactory with redirect_stderr(NullWriter()): import psutil @@ -95,12 +97,13 @@ class ServerOutBuf: # TODO: Do not send data to clients who do not have permission to view # this server's console - self.helper.websocket_helper.broadcast_page_params( - "/panel/server_detail", - {"id": self.server_id}, - "vterm_new_line", - {"line": highlighted + "
    "}, - ) + if len(WebSocketManager().clients) > 0: + WebSocketManager().broadcast_page_params( + "/panel/server_detail", + {"id": self.server_id}, + "vterm_new_line", + {"line": highlighted + "
    "}, + ) # ********************************************************************************** @@ -169,6 +172,45 @@ class ServerInstance: self.stats_helper.server_crash_reset() self.stats_helper.set_update(False) + @staticmethod + def callback(called_func): + # Usage of @callback on method + # definition to run a webhook check + # on method completion + def wrapper(*args, **kwargs): + res = None + logger.debug("Checking for callbacks") + try: + res = called_func(*args, **kwargs) + finally: + events = WebhookFactory.get_monitored_events() + if called_func.__name__ in events: + server_webhooks = HelpersWebhooks.get_webhooks_by_server( + args[0].server_id, True + ) + for swebhook in server_webhooks: + if called_func.__name__ in str(swebhook.trigger).split(","): + logger.info( + f"Found callback for event {called_func.__name__}" + f" for server {args[0].server_id}" + ) + webhook = HelpersWebhooks.get_webhook_by_id(swebhook.id) + webhook_provider = WebhookFactory.create_provider( + webhook["webhook_type"] + ) + if res is not False and swebhook.enabled: + webhook_provider.send( + bot_name=webhook["bot_name"], + server_name=args[0].name, + title=webhook["name"], + url=webhook["url"], + message=webhook["body"], + color=webhook["color"], + ) + return res + + return wrapper + # ********************************************************************************** # Minecraft Server Management # ********************************************************************************** @@ -257,6 +299,23 @@ class ServerInstance: seconds=5, id="stats_" + str(self.server_id), ) + logger.info(f"Saving server statistics {self.name} every {30} seconds") + Console.info(f"Saving server statistics {self.name} every {30} seconds") + try: + self.server_scheduler.add_job( + self.record_server_stats, + "interval", + seconds=30, + id="save_stats_" + str(self.server_id), + ) + except ConflictingIdError: + self.server_scheduler.remove_job("save_stats_" + str(self.server_id)) + self.server_scheduler.add_job( + self.record_server_stats, + "interval", + seconds=30, + id="save_stats_" + str(self.server_id), + ) def setup_server_run_command(self): # configure the server @@ -319,6 +378,7 @@ class ServerInstance: logger.critical(f"Unable to write/access {self.server_path}") Console.critical(f"Unable to write/access {self.server_path}") + @callback def start_server(self, user_id, forge_install=False): if not user_id: user_lang = self.helper.get_setting("language") @@ -328,7 +388,7 @@ class ServerInstance: # Checks if user is currently attempting to move global server # dir if self.helper.dir_migration: - self.helper.websocket_helper.broadcast_user( + WebSocketManager().broadcast_user( user_id, "send_start_error", { @@ -343,7 +403,7 @@ class ServerInstance: if self.stats_helper.get_import_status() and not forge_install: if user_id: - self.helper.websocket_helper.broadcast_user( + WebSocketManager().broadcast_user( user_id, "send_start_error", { @@ -389,7 +449,7 @@ class ServerInstance: e_flag = True if not e_flag and self.settings["type"] == "minecraft-java": if user_id: - self.helper.websocket_helper.broadcast_user( + WebSocketManager().broadcast_user( user_id, "send_eula_bootbox", {"id": self.server_id} ) else: @@ -422,7 +482,7 @@ class ServerInstance: except: if user_id: - self.helper.websocket_helper.broadcast_user( + WebSocketManager().broadcast_user( user_id, "send_start_error", { @@ -458,7 +518,7 @@ class ServerInstance: f"Server {self.name} failed to start with error code: {ex}" ) if user_id: - self.helper.websocket_helper.broadcast_user( + WebSocketManager().broadcast_user( user_id, "send_start_error", { @@ -485,7 +545,7 @@ class ServerInstance: # Checks for java on initial fail if not self.helper.detect_java(): if user_id: - self.helper.websocket_helper.broadcast_user( + WebSocketManager().broadcast_user( user_id, "send_start_error", { @@ -499,7 +559,7 @@ class ServerInstance: f"Server {self.name} failed to start with error code: {ex}" ) if user_id: - self.helper.websocket_helper.broadcast_user( + WebSocketManager().broadcast_user( user_id, "send_start_error", { @@ -546,7 +606,7 @@ class ServerInstance: self.stats_helper.set_first_run() loc_server_port = self.stats_helper.get_server_stats()["server_port"] # Sends port reminder message. - self.helper.websocket_helper.broadcast_user( + WebSocketManager().broadcast_user( user_id, "send_start_error", { @@ -558,15 +618,11 @@ class ServerInstance: server_users = PermissionsServers.get_server_user_list(self.server_id) for user in server_users: if user != user_id: - self.helper.websocket_helper.broadcast_user( - user, "send_start_reload", {} - ) + WebSocketManager().broadcast_user(user, "send_start_reload", {}) else: server_users = PermissionsServers.get_server_user_list(self.server_id) for user in server_users: - self.helper.websocket_helper.broadcast_user( - user, "send_start_reload", {} - ) + WebSocketManager().broadcast_user(user, "send_start_reload", {}) else: logger.warning( f"Server PID {self.process.pid} died right after starting " @@ -598,7 +654,7 @@ class ServerInstance: def check_internet_thread(self, user_id, user_lang): if user_id: if not Helpers.check_internet(): - self.helper.websocket_helper.broadcast_user( + WebSocketManager().broadcast_user( user_id, "send_start_error", { @@ -725,9 +781,7 @@ class ServerInstance: server_users = PermissionsServers.get_server_user_list(self.server_id) for user in server_users: - self.helper.websocket_helper.broadcast_user( - user, "send_start_reload", {} - ) + WebSocketManager().broadcast_user(user, "send_start_reload", {}) break def stop_crash_detection(self): @@ -768,6 +822,7 @@ class ServerInstance: if self.server_thread: self.server_thread.join() + @callback def stop_server(self): running = self.check_running() if not running: @@ -785,6 +840,7 @@ class ServerInstance: f"Assuming it was never started." ) if self.settings["stop_command"]: + logger.info(f"Stop command requested for {self.settings['server_name']}.") self.send_command(self.settings["stop_command"]) self.write_player_cache() else: @@ -840,7 +896,7 @@ class ServerInstance: self.record_server_stats() for user in server_users: - self.helper.websocket_helper.broadcast_user(user, "send_start_reload", {}) + WebSocketManager().broadcast_user(user, "send_start_reload", {}) def restart_threaded_server(self, user_id): bu_conf = HelpersManagement.get_backup_config(self.server_id) @@ -854,6 +910,9 @@ class ServerInstance: if not self.check_running(): self.run_threaded_server(user_id) else: + logger.info( + f"Restart command detected. Sending stop command to {self.server_id}." + ) self.stop_threaded_server() time.sleep(2) self.run_threaded_server(user_id) @@ -875,6 +934,7 @@ class ServerInstance: self.last_rc = poll return False + @callback def send_command(self, command): if not self.check_running() and command.lower() != "start": logger.warning(f'Server not running, unable to send command "{command}"') @@ -887,6 +947,7 @@ class ServerInstance: self.process.stdin.flush() return True + @callback def crash_detected(self, name): # clear the old scheduled watcher task self.server_scheduler.remove_job(f"c_{self.server_id}") @@ -907,6 +968,7 @@ class ServerInstance: f"The server {name} has crashed and will be restarted. " f"Restarting server" ) + self.run_threaded_server(None) return True logger.critical( @@ -919,6 +981,7 @@ class ServerInstance: ) return False + @callback def kill(self): logger.info(f"Terminating server {self.server_id} and all child processes") try: @@ -1007,6 +1070,7 @@ class ServerInstance: f.write("eula=true") self.run_threaded_server(user_id) + @callback def backup_server(self): if self.settings["backup_path"] == "": logger.critical("Backup path is None. Canceling Backup!") @@ -1040,18 +1104,11 @@ class ServerInstance: logger.info(f"Backup Thread started for server {self.settings['server_name']}.") def a_backup_server(self): - if len(self.helper.websocket_helper.clients) > 0: - self.helper.websocket_helper.broadcast_page_params( - "/panel/server_detail", - {"id": str(self.server_id)}, - "backup_reload", - {"percent": 0, "total_files": 0}, - ) was_server_running = None logger.info(f"Starting server {self.name} (ID {self.server_id}) backup") server_users = PermissionsServers.get_server_user_list(self.server_id) for user in server_users: - self.helper.websocket_helper.broadcast_user( + WebSocketManager().broadcast_user( user, "notification", self.helper.translation.translate( @@ -1126,8 +1183,8 @@ class ServerInstance: self.is_backingup = False logger.info(f"Backup of server: {self.name} completed") results = {"percent": 100, "total_files": 0, "current_file": 0} - if len(self.helper.websocket_helper.clients) > 0: - self.helper.websocket_helper.broadcast_page_params( + if len(WebSocketManager().clients) > 0: + WebSocketManager().broadcast_page_params( "/panel/server_detail", {"id": str(self.server_id)}, "backup_status", @@ -1135,7 +1192,7 @@ class ServerInstance: ) server_users = PermissionsServers.get_server_user_list(self.server_id) for user in server_users: - self.helper.websocket_helper.broadcast_user( + WebSocketManager().broadcast_user( user, "notification", self.helper.translation.translate( @@ -1164,8 +1221,8 @@ class ServerInstance: f"Failed to create backup of server {self.name} (ID {self.server_id})" ) results = {"percent": 100, "total_files": 0, "current_file": 0} - if len(self.helper.websocket_helper.clients) > 0: - self.helper.websocket_helper.broadcast_page_params( + if len(WebSocketManager().clients) > 0: + WebSocketManager().broadcast_page_params( "/panel/server_detail", {"id": str(self.server_id)}, "backup_status", @@ -1182,8 +1239,8 @@ class ServerInstance: def backup_status(self, source_path, dest_path): results = Helpers.calc_percent(source_path, dest_path) self.backup_stats = results - if len(self.helper.websocket_helper.clients) > 0: - self.helper.websocket_helper.broadcast_page_params( + if len(WebSocketManager().clients) > 0: + WebSocketManager().broadcast_page_params( "/panel/server_detail", {"id": str(self.server_id)}, "backup_status", @@ -1228,6 +1285,7 @@ class ServerInstance: if f["path"].endswith(".zip") ] + @callback def jar_update(self): self.stats_helper.set_update(True) update_thread = threading.Thread( @@ -1286,14 +1344,14 @@ class ServerInstance: self.stop_threaded_server() else: was_started = False - if len(self.helper.websocket_helper.clients) > 0: + if len(WebSocketManager().clients) > 0: # There are clients self.check_update() message = ( ' UPDATING...' ) for user in server_users: - self.helper.websocket_helper.broadcast_user_page( + WebSocketManager().broadcast_user_page( "/panel/server_detail", user, "update_button_status", @@ -1346,7 +1404,7 @@ class ServerInstance: # check if backup was successful if self.last_backup_failed: for user in server_users: - self.helper.websocket_helper.broadcast_user( + WebSocketManager().broadcast_user( user, "notification", "Backup failed for " + self.name + ". canceling update.", @@ -1392,11 +1450,11 @@ class ServerInstance: logger.info("Executable updated successfully. Starting Server") self.stats_helper.set_update(False) - if len(self.helper.websocket_helper.clients) > 0: + if len(WebSocketManager().clients) > 0: # There are clients self.check_update() for user in server_users: - self.helper.websocket_helper.broadcast_user( + WebSocketManager().broadcast_user( user, "notification", "Executable update finished for " + self.name, @@ -1404,7 +1462,7 @@ class ServerInstance: # sleep so first notif can completely run time.sleep(3) for user in server_users: - self.helper.websocket_helper.broadcast_user_page( + WebSocketManager().broadcast_user_page( "/panel/server_detail", user, "update_button_status", @@ -1414,10 +1472,10 @@ class ServerInstance: "wasRunning": was_started, }, ) - self.helper.websocket_helper.broadcast_user_page( + WebSocketManager().broadcast_user_page( user, "/panel/dashboard", "send_start_reload", {} ) - self.helper.websocket_helper.broadcast_user( + WebSocketManager().broadcast_user( user, "notification", "Executable update finished for " + self.name, @@ -1434,7 +1492,7 @@ class ServerInstance: self.run_threaded_server(HelperUsers.get_user_id_by_name("system")) else: for user in server_users: - self.helper.websocket_helper.broadcast_user( + WebSocketManager().broadcast_user( user, "notification", "Executable update failed for " @@ -1444,7 +1502,7 @@ class ServerInstance: logger.error("Executable download failed.") self.stats_helper.set_update(False) for user in server_users: - self.helper.websocket_helper.broadcast_user(user, "remove_spinner", {}) + WebSocketManager().broadcast_user(user, "remove_spinner", {}) def start_dir_calc_task(self): server_dt = HelperServers.get_server_data_by_id(self.server_id) @@ -1473,7 +1531,7 @@ class ServerInstance: def realtime_stats(self): # only get stats if clients are connected. # no point in burning cpu - if len(self.helper.websocket_helper.clients) > 0: + if len(WebSocketManager().clients) > 0: total_players = 0 max_players = 0 servers_ping = [] @@ -1504,50 +1562,43 @@ class ServerInstance: "crashed": self.is_crashed, } ) - if len(self.helper.websocket_helper.clients) > 0: - self.helper.websocket_helper.broadcast_page_params( - "/panel/server_detail", - {"id": str(self.server_id)}, - "update_server_details", - { - "id": raw_ping_result.get("id"), - "started": raw_ping_result.get("started"), - "running": raw_ping_result.get("running"), - "cpu": raw_ping_result.get("cpu"), - "mem": raw_ping_result.get("mem"), - "mem_percent": raw_ping_result.get("mem_percent"), - "world_name": raw_ping_result.get("world_name"), - "world_size": raw_ping_result.get("world_size"), - "server_port": raw_ping_result.get("server_port"), - "int_ping_results": raw_ping_result.get("int_ping_results"), - "online": raw_ping_result.get("online"), - "max": raw_ping_result.get("max"), - "players": raw_ping_result.get("players"), - "desc": raw_ping_result.get("desc"), - "version": raw_ping_result.get("version"), - "icon": raw_ping_result.get("icon"), - "crashed": self.is_crashed, - "created": datetime.datetime.now().strftime( - "%Y/%m/%d, %H:%M:%S" - ), - "players_cache": self.player_cache, - }, - ) + + WebSocketManager().broadcast_page_params( + "/panel/server_detail", + {"id": str(self.server_id)}, + "update_server_details", + { + "id": raw_ping_result.get("id"), + "started": raw_ping_result.get("started"), + "running": raw_ping_result.get("running"), + "cpu": raw_ping_result.get("cpu"), + "mem": raw_ping_result.get("mem"), + "mem_percent": raw_ping_result.get("mem_percent"), + "world_name": raw_ping_result.get("world_name"), + "world_size": raw_ping_result.get("world_size"), + "server_port": raw_ping_result.get("server_port"), + "int_ping_results": raw_ping_result.get("int_ping_results"), + "online": raw_ping_result.get("online"), + "max": raw_ping_result.get("max"), + "players": raw_ping_result.get("players"), + "desc": raw_ping_result.get("desc"), + "version": raw_ping_result.get("version"), + "icon": raw_ping_result.get("icon"), + "crashed": self.is_crashed, + "created": datetime.datetime.now().strftime("%Y/%m/%d, %H:%M:%S"), + "players_cache": self.player_cache, + }, + ) total_players += int(raw_ping_result.get("online")) max_players += int(raw_ping_result.get("max")) - self.record_server_stats() + # self.record_server_stats() - if (len(servers_ping) > 0) & ( - len(self.helper.websocket_helper.clients) > 0 - ): + if len(servers_ping) > 0: try: - self.helper.websocket_helper.broadcast_page( + WebSocketManager().broadcast_page( "/panel/dashboard", "update_server_status", servers_ping ) - self.helper.websocket_helper.broadcast_page( - "/status", "update_server_status", servers_ping - ) except: Console.critical("Can't broadcast server status to websocket") @@ -1566,7 +1617,6 @@ class ServerInstance: # process stats p_stats = Stats._try_get_process_stats(self.process, self.check_running()) - internal_ip = server["server_ip"] server_port = server["server_port"] server_name = server.get("server_name", f"ID#{server_id}") @@ -1612,6 +1662,7 @@ class ServerInstance: "players": ping_data.get("players", False), "desc": ping_data.get("server_description", False), "version": ping_data.get("server_version", False), + "icon": ping_data.get("server_icon"), } else: server_stats = { @@ -1630,6 +1681,7 @@ class ServerInstance: "players": False, "desc": False, "version": False, + "icon": None, } return server_stats @@ -1637,7 +1689,7 @@ class ServerInstance: def get_server_players(self): server = HelperServers.get_server_data_by_id(self.server_id) - logger.info(f"Getting players for server {server}") + logger.debug(f"Getting players for server {server['server_name']}") internal_ip = server["server_ip"] server_port = server["server_port"] @@ -1678,7 +1730,6 @@ class ServerInstance: } server_stats = {} - server = HelperServers.get_server_obj(server_id) if not server: return {} server_dt = HelperServers.get_server_data_by_id(server_id) @@ -1848,3 +1899,7 @@ class ServerInstance: labelnames=["server_id"], registry=self.server_registry, ) + + def get_server_history(self): + history = self.stats_helper.get_history_stats(self.server_id, 1) + return history diff --git a/app/classes/shared/tasks.py b/app/classes/shared/tasks.py index f896f8ff..6205bde7 100644 --- a/app/classes/shared/tasks.py +++ b/app/classes/shared/tasks.py @@ -20,6 +20,7 @@ from app.classes.shared.file_helpers import FileHelpers from app.classes.shared.helpers import Helpers from app.classes.shared.main_controller import Controller from app.classes.web.tornado_handler import Webserver +from app.classes.shared.websocket_manager import WebSocketManager logger = logging.getLogger("apscheduler") scheduler_intervals = { @@ -41,11 +42,10 @@ scheduler_intervals = { class TasksManager: controller: Controller - def __init__(self, helper, controller): + def __init__(self, helper, controller, file_helper): self.helper: Helpers = helper self.controller: Controller = controller - self.tornado: Webserver = Webserver(helper, controller, self) - + self.tornado: Webserver = Webserver(helper, controller, self, file_helper) try: self.tz = get_localzone() except ZoneInfoNotFoundError as e: @@ -102,7 +102,7 @@ class TasksManager: ) except: logger.error( - "Server value requested does not exist! " + f"Server value {cmd['server_id']} requested does not exist! " "Purging item from waiting commands." ) continue @@ -695,10 +695,10 @@ class TasksManager: host_stats.get("mem_percent") ) - if len(self.helper.websocket_helper.clients) > 0: + if len(WebSocketManager().clients) > 0: # There are clients try: - self.helper.websocket_helper.broadcast_page( + WebSocketManager().broadcast_page( "/panel/dashboard", "update_host_stats", { @@ -715,7 +715,7 @@ class TasksManager: }, ) except: - self.helper.websocket_helper.broadcast_page( + WebSocketManager().broadcast_page( "/panel/dashboard", "update_host_stats", { @@ -733,12 +733,21 @@ class TasksManager: def check_for_updates(self): logger.info("Checking for Crafty updates...") self.helper.update_available = self.helper.check_remote_version() + remote = self.helper.update_available if self.helper.update_available: logger.info(f"Found new version {self.helper.update_available}") else: logger.info( "No updates found! You are on the most up to date Crafty version." ) + if self.helper.update_available: + self.helper.update_available = { + "id": str(remote), + "title": f"{remote} Update Available", + "date": "", + "desc": "Release notes are available by clicking this notification.", + "link": "https://gitlab.com/crafty-controller/crafty-4/-/releases", + } logger.info("Refreshing Gravatar PFPs...") for user in HelperUsers.get_all_users(): if user.email: diff --git a/app/classes/web/websocket_helper.py b/app/classes/shared/websocket_manager.py similarity index 80% rename from app/classes/web/websocket_helper.py rename to app/classes/shared/websocket_manager.py index cd70df50..f48adef8 100644 --- a/app/classes/web/websocket_helper.py +++ b/app/classes/shared/websocket_manager.py @@ -1,26 +1,25 @@ import json import logging +from app.classes.shared.singleton import Singleton from app.classes.shared.console import Console +from app.classes.models.users import HelperUsers logger = logging.getLogger(__name__) -class WebSocketHelper: - def __init__(self, helper): - self.helper = helper +class WebSocketManager(metaclass=Singleton): + def __init__(self): self.clients = set() def add_client(self, client): self.clients.add(client) def remove_client(self, client): - self.clients.remove(client) - - def send_message(self, client, event_type: str, data): - if client.check_auth(): - message = str(json.dumps({"event": event_type, "data": data})) - client.write_message_helper(message) + if client in self.clients: + self.clients.remove(client) + else: + logger.exception("Error caught while removing unknown WebSocket client") def broadcast(self, event_type: str, data): logger.debug( @@ -29,13 +28,21 @@ class WebSocketHelper: ) for client in self.clients: try: - self.send_message(client, event_type, data) + client.send_message(event_type, data) except Exception as e: logger.exception( f"Error caught while sending WebSocket message to " f"{client.get_remote_ip()} {e}" ) + def broadcast_to_admins(self, event_type: str, data): + def filter_fn(client): + if client.get_user_id in HelperUsers.get_super_user_list(): + return True + return False + + self.broadcast_with_fn(filter_fn, event_type, data) + def broadcast_page(self, page: str, event_type: str, data): def filter_fn(client): return client.page == page @@ -90,13 +97,14 @@ class WebSocketHelper: static_clients = self.clients clients = list(filter(filter_fn, static_clients)) logger.debug( - f"Sending to {len(clients)} out of {len(self.clients)} " + f"Sending to {len(clients)} \ + out of {len(self.clients)} " f"clients: {json.dumps({'event': event_type, 'data': data})}" ) for client in clients[:]: try: - self.send_message(client, event_type, data) + client.send_message(event_type, data) except Exception as e: logger.exception( f"Error catched while sending WebSocket message to " diff --git a/app/classes/web/ajax_handler.py b/app/classes/web/ajax_handler.py deleted file mode 100644 index df2d701d..00000000 --- a/app/classes/web/ajax_handler.py +++ /dev/null @@ -1,698 +0,0 @@ -import os -import html -import pathlib -import re -import logging -import time -import urllib.parse -import nh3 -import tornado.web -import tornado.escape - -from app.classes.models.server_permissions import EnumPermissionsServer -from app.classes.shared.console import Console -from app.classes.shared.helpers import Helpers -from app.classes.shared.server import ServerOutBuf -from app.classes.web.base_handler import BaseHandler - -logger = logging.getLogger(__name__) - - -class AjaxHandler(BaseHandler): - def render_page(self, template, page_data): - self.render( - template, - data=page_data, - translate=self.translator.translate, - ) - - @tornado.web.authenticated - def get(self, page): - _, _, exec_user = self.current_user - error = nh3.clean(self.get_argument("error", "WTF Error!")) - - template = "panel/denied.html" - - page_data = {"user_data": exec_user, "error": error} - - if page == "error": - template = "public/error.html" - self.render_page(template, page_data) - - elif page == "server_log": - server_id = self.get_argument("id", None) - full_log = self.get_argument("full", False) - - if server_id is None: - logger.warning("Server ID not found in server_log ajax call") - self.redirect("/panel/error?error=Server ID Not Found") - return - - server_id = nh3.clean(server_id) - - server_data = self.controller.servers.get_server_data_by_id(server_id) - if not server_data: - logger.warning("Server Data not found in server_log ajax call") - self.redirect("/panel/error?error=Server ID Not Found") - return - - if not server_data["log_path"]: - logger.warning( - f"Log path not found in server_log ajax call ({server_id})" - ) - - if full_log: - log_lines = self.helper.get_setting("max_log_lines") - data = Helpers.tail_file( - # If the log path is absolute it returns it as is - # If it is relative it joins the paths below like normal - pathlib.Path(server_data["path"], server_data["log_path"]), - log_lines, - ) - else: - data = ServerOutBuf.lines.get(server_id, []) - - for line in data: - try: - line = re.sub("(\033\\[(0;)?[0-9]*[A-z]?(;[0-9])?m?)", "", line) - line = re.sub("[A-z]{2}\b\b", "", line) - line = self.helper.log_colors(html.escape(line)) - self.write(f"{line}
    ") - # self.write(d.encode("utf-8")) - - except Exception as e: - logger.warning(f"Skipping Log Line due to error: {e}") - - elif page == "announcements": - data = Helpers.get_announcements() - page_data["notify_data"] = data - self.render_page("ajax/notify.html", page_data) - - elif page == "get_zip_tree": - path = self.get_argument("path", None) - - self.write( - Helpers.get_os_understandable_path(path) - + "\n" - + Helpers.generate_zip_tree(path) - ) - self.finish() - - elif page == "get_zip_dir": - path = self.get_argument("path", None) - - self.write( - Helpers.get_os_understandable_path(path) - + "\n" - + Helpers.generate_zip_dir(path) - ) - self.finish() - - elif page == "get_backup_tree": - server_id = self.get_argument("id", None) - folder = self.get_argument("path", None) - - output = "" - - dir_list = [] - unsorted_files = [] - file_list = os.listdir(folder) - for item in file_list: - if os.path.isdir(os.path.join(folder, item)): - dir_list.append(item) - else: - unsorted_files.append(item) - file_list = sorted(dir_list, key=str.casefold) + sorted( - unsorted_files, key=str.casefold - ) - output += f"""