diff --git a/.dockerignore b/.dockerignore index 5ab07afd..fd5c9add 100644 --- a/.dockerignore +++ b/.dockerignore @@ -11,6 +11,7 @@ docker-compose.yml.example .gitlab/ .gitignore .gitlab-ci.yml +lang_sort_log.txt # root .editorconfig diff --git a/.gitignore b/.gitignore index 7f3ad858..132a2a26 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ env.bak/ venv.bak/ .idea/ +/import/ /imports/ /servers/ /app/frontend/static/assets/images/auth/custom/ @@ -35,3 +36,4 @@ default.json app/config/ docker/* !docker/docker-compose.yml +lang_sort_log.txt diff --git a/.gitlab/lint.yml b/.gitlab/lint.yml index 85a09c39..f335dea8 100644 --- a/.gitlab/lint.yml +++ b/.gitlab/lint.yml @@ -81,3 +81,26 @@ sonarcloud-check: - .sonar/cache script: - sonar-scanner + +# Lang file checking +lang-check: + stage: lint + image: alpine:latest + tags: + - docker + rules: + - if: "$CODE_QUALITY_DISABLED" + when: never + - if: "$CI_COMMIT_TAG || $CI_COMMIT_BRANCH" + allow_failure: true + before_script: + - apk add --no-cache jq bash + script: + - chmod +x .gitlab/scripts/lang_sort.sh + - bash .gitlab/scripts/lang_sort.sh ./app/translations/ + after_script: + - if [ -f .gitlab/scripts/lang_sort_log.txt ]; then cat .gitlab/scripts/lang_sort_log.txt; fi + artifacts: + paths: + - .gitlab/scripts/lang_sort_log.txt + expire_in: 1 week diff --git a/.gitlab/scripts/lang_sort.sh b/.gitlab/scripts/lang_sort.sh new file mode 100644 index 00000000..5710ce1b --- /dev/null +++ b/.gitlab/scripts/lang_sort.sh @@ -0,0 +1,95 @@ +#!/bin/bash + +# Ensure locale is set to C for predictable sorting +export LC_ALL=C +export LC_COLLATE=C + +# Get the script's own path +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# Directory containing the JSON files to sort +DIR="$1" +found_missing_keys=false + + +##### Log Setup ##### +# Log file path +LOGFILE="${SCRIPT_DIR}/lang_sort_log.txt" + +# Redirect stdout and stderr to the logfile +exec > "${LOGFILE}" 2>&1 +##################### + + +##### Exit Gates ##### +# Check if jq is installed +if ! command -v jq &> /dev/null +then + echo "jq could not be found, please install jq first." + exit +fi + +# Check for directory argument +if [ "$#" -ne 1 ]; then + echo "Usage: $0 /path/to/translations" + exit +fi + +# Check if en_EN.json exists in the directory +if [[ ! -f "${DIR}/en_EN.json" ]]; then + echo "The file en_EN.json does not exist in ${DIR}.Ensure you have the right directory, Exiting." + exit +fi +###################### + + +# Sort keys of the en_EN.json file with 4-space indentation and overwrite it +jq -S --indent 4 '.' "${DIR}/en_EN.json" > "${DIR}/en_EN.json.tmp" && mv "${DIR}/en_EN.json.tmp" "${DIR}/en_EN.json" + +# Function to recursively find all keys in a JSON object +function get_keys { + jq -r 'paths(scalars) | join("/")' "$1" +} + +# Get keys and subkeys from en_EN.json +ref_keys=$(mktemp) +get_keys "${DIR}/en_EN.json" | sort > "${ref_keys}" + +# Iterate over each .json file in the directory +for file in "${DIR}"/*.json; do + # Check if file is a regular file and not en_EN.json, and does not contain "_incomplete" in its name + if [[ -f "${file}" && "${file}" != "${DIR}/en_EN.json" && ! "${file}" =~ _incomplete ]]; then + + # Get keys and subkeys from the current file + current_keys=$(mktemp) + get_keys "${file}" | sort > "${current_keys}" + + # Display keys present in en_EN.json but not in the current file + missing_keys=$(comm -23 "${ref_keys}" "${current_keys}") + if [[ -n "${missing_keys}" ]]; then + found_missing_keys=true + echo -e "\nKeys/subkeys present in en_EN.json but missing in $(basename "${file}"): " + echo "${missing_keys}" + fi + + # Sort keys of the JSON file and overwrite the original file + jq -S --indent 4 '.' "${file}" > "${file}.tmp" && mv "${file}.tmp" "${file}" + + # Remove the temporary file + rm -f "${current_keys}" + fi +done + +# Remove the temporary file +rm -f "${ref_keys}" + +if ${found_missing_keys}; then + echo -e "\n\nSorting complete!" + echo "Comparison found missing keys, Please Review!" + echo "-------------------------------------------------------------------" + echo "If there are stale translations, you can exclude with '_incomplete'" + echo " e.g. lol_EN_incomplete.json" + echo "-------------------------------------------------------------------" + exit 1 +else + echo -e "\n\nComparison and Sorting complete!" +fi diff --git a/CHANGELOG.md b/CHANGELOG.md index bbb39d7f..627014a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,49 @@ # Changelog +## --- [4.2.0] - 2023/10/18 +### New features +- 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)) +- Implementation of OpenMetrics endpoints, for use with services such as Prometheus ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/624)) +### 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)) +- Add a wait to the call for the directory so we can make sure the wait dialogue has time to show up first ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/637)) +- Fix bug where a reaction loop could be created, but would be cut short by an error when the loop occurred ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/636)) +- Use controller on update user call ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/640)) +- Move `imports` to `import/upload` in bind mount to better serve users on unraid with limited vdisk storage ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/642)) +- Fix bug where everytime a page was loaded user settings would be reset #286 ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/643)) +- Fix tooltip info icon on server config page ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/647)) +- Fix quick disable toggle on schedules list ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/649)) +### Refactor +- 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)) +- Bump orjson to `3.9.7` for python `3.12` support ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/638)) +- Bump all Crafty required python dependancies, maintaining minimum `3.9` support ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/639)) Revert peewee bump ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/651)) +- Better optimize and refactor docker launcher sh ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/642)) +- Improve pop-up notifications with Toasts ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/641)) +- Move username and password settings to buttons on panel config ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/643)) +- Remove external references from front end deps ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/648)) +### Lang +- `fr_FR` Translation Updated to latest en_EN ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/646)) +- `de_DE`, `fr_FR`, `lol_EN`, `lv_LV`, `nl_BE`, `pl_PL` Translations Updated to latest `en_EN` ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/645)) +

+ ## --- [4.1.3] - 2023/07/18 ### Bug fixes - Include tzdata in Docker image ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/604)) diff --git a/README.md b/README.md index 2d422611..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.3 +# 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 7c423da9..7085b503 100644 --- a/app/classes/controllers/management_controller.py +++ b/app/classes/controllers/management_controller.py @@ -1,7 +1,9 @@ import logging import queue -from app.classes.models.management import HelpersManagement +from prometheus_client import CollectorRegistry, Gauge + +from app.classes.models.management import HelpersManagement, HelpersWebhooks from app.classes.models.servers import HelperServers logger = logging.getLogger(__name__) @@ -11,6 +13,8 @@ class ManagementController: def __init__(self, management_helper): self.management_helper = management_helper self.command_queue = queue.Queue() + self.host_registry = CollectorRegistry() + self.init_host_registries() # ********************************************************************************** # Config Methods @@ -54,6 +58,19 @@ class ManagementController: def add_crafty_row(): HelpersManagement.create_crafty_row() + def init_host_registries(self): + # REGISTRY Entries for Server Stats functions + self.cpu_usage = Gauge( + name="CPU_Usage", + documentation="The CPU usage of the server", + registry=self.host_registry, + ) + self.mem_usage_percent = Gauge( + name="Mem_Usage", + documentation="The Memory usage of the server", + registry=self.host_registry, + ) + # ********************************************************************************** # Commands Methods # ********************************************************************************** @@ -79,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( @@ -206,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..ed53ad61 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, }, }, @@ -212,14 +214,14 @@ class UsersController: limit_server_creation = 0 limit_user_creation = 0 limit_role_creation = 0 - - PermissionsCrafty.add_or_update_user( - user_id, - permissions_mask, - limit_server_creation, - limit_user_creation, - limit_role_creation, - ) + if user_crafty_data: + PermissionsCrafty.add_or_update_user( + user_id, + permissions_mask, + limit_server_creation, + limit_user_creation, + limit_role_creation, + ) self.users_helper.delete_user_roles(user_id, removed_roles) diff --git a/app/classes/minecraft/mc_ping.py b/app/classes/minecraft/mc_ping.py index 51fcaa2c..c5cb9916 100644 --- a/app/classes/minecraft/mc_ping.py +++ b/app/classes/minecraft/mc_ping.py @@ -16,6 +16,12 @@ logger = logging.getLogger(__name__) class Server: def __init__(self, data): + if isinstance(data, str): + logger.error( + "Failed to calculate stats. Expected object. " + f"Server returned string: {data}" + ) + return self.description = data.get("description") # print(self.description) if isinstance(self.description, dict): 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 14f85ad3..8473ed12 100644 --- a/app/classes/models/server_stats.py +++ b/app/classes/models/server_stats.py @@ -8,6 +8,7 @@ from app.classes.shared.helpers import Helpers from app.classes.shared.main_models import DatabaseShortcuts from app.classes.shared.migration import MigrationManager + try: from peewee import ( SqliteDatabase, @@ -50,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) @@ -141,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): @@ -179,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 26fdd2f0..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__) @@ -92,6 +93,9 @@ class MainPrompt(cmd.Cmd): self.controller.users.update_user(user_id, {"password": new_pass}) + def do_get_users(self, _line): + Console.info(self.controller.users.get_all_usernames()) + @staticmethod def do_threads(_line): for thread in threading.enumerate(): @@ -115,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..23586696 100644 --- a/app/classes/shared/main_controller.py +++ b/app/classes/shared/main_controller.py @@ -5,13 +5,14 @@ from datetime import datetime import platform import shutil import time +import json import logging import threading +from zoneinfo import ZoneInfoNotFoundError from peewee import DoesNotExist # TZLocal is set as a hidden import on win pipeline from tzlocal import get_localzone -from tzlocal.utils import ZoneInfoNotFoundError from apscheduler.schedulers.background import BackgroundScheduler from app.classes.models.server_permissions import EnumPermissionsServer @@ -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 c1a11158..8448f656 100644 --- a/app/classes/shared/server.py +++ b/app/classes/shared/server.py @@ -16,22 +16,27 @@ import json from zoneinfo import ZoneInfo # TZLocal is set as a hidden import on win pipeline +from zoneinfo import ZoneInfoNotFoundError 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 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 @@ -40,6 +45,45 @@ with redirect_stderr(NullWriter()): logger = logging.getLogger(__name__) +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 + + class ServerOutBuf: lines = {} @@ -92,12 +136,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 + "
    "}, + ) # ********************************************************************************** @@ -133,6 +178,8 @@ class ServerInstance: self.server_object = HelperServers.get_server_obj(self.server_id) self.stats_helper = HelperServerStats(self.server_id) self.last_backup_failed = False + self.server_registry = CollectorRegistry() + try: with open( os.path.join(self.server_object.path, "db_stats", "players_cache.json"), @@ -152,6 +199,7 @@ class ServerInstance: self.tz = ZoneInfo("Europe/London") self.server_scheduler = BackgroundScheduler(timezone=str(self.tz)) self.dir_scheduler = BackgroundScheduler(timezone=str(self.tz)) + self.init_registries() self.server_scheduler.start() self.dir_scheduler.start() self.start_dir_calc_task() @@ -251,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 @@ -313,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") @@ -322,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", { @@ -337,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", { @@ -383,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: @@ -416,7 +482,7 @@ class ServerInstance: except: if user_id: - self.helper.websocket_helper.broadcast_user( + WebSocketManager().broadcast_user( user_id, "send_start_error", { @@ -452,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", { @@ -479,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", { @@ -493,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", { @@ -540,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", { @@ -552,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 " @@ -592,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", { @@ -719,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): @@ -762,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: @@ -779,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: @@ -834,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) @@ -848,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) @@ -869,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}"') @@ -881,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}") @@ -901,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( @@ -913,6 +981,7 @@ class ServerInstance: ) return False + @callback def kill(self): logger.info(f"Terminating server {self.server_id} and all child processes") try: @@ -1001,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!") @@ -1034,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( @@ -1120,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", @@ -1129,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( @@ -1158,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", @@ -1176,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", @@ -1222,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( @@ -1280,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", @@ -1340,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.", @@ -1386,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, @@ -1398,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", @@ -1408,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, @@ -1428,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 " @@ -1438,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) @@ -1467,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 = [] @@ -1498,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") @@ -1560,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}") @@ -1606,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 = { @@ -1624,6 +1681,7 @@ class ServerInstance: "players": False, "desc": False, "version": False, + "icon": None, } return server_stats @@ -1631,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"] @@ -1672,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) @@ -1799,9 +1856,50 @@ class ServerInstance: server_stats = self.get_servers_stats() self.stats_helper.insert_server_stats(server_stats) + self.cpu_usage.labels(f"{self.server_id}").set(server_stats.get("cpu")) + self.mem_usage_percent.labels(f"{self.server_id}").set( + server_stats.get("mem_percent") + ) + self.minecraft_version.labels(f"{self.server_id}").info( + {"version": f"{server_stats.get('version')}"} + ) + self.online_players.labels(f"{self.server_id}").set(server_stats.get("online")) + # delete old data max_age = self.helper.get_setting("history_max_age") now = datetime.datetime.now() minimum_to_exist = now - datetime.timedelta(days=max_age) self.stats_helper.remove_old_stats(minimum_to_exist) + + def init_registries(self): + # REGISTRY Entries for Server Stats functions + self.cpu_usage = Gauge( + name="CPU_Usage", + documentation="The CPU usage of the server", + labelnames=["server_id"], + registry=self.server_registry, + ) + self.mem_usage_percent = Gauge( + name="Mem_Usage", + documentation="The Memory usage of the server", + labelnames=["server_id"], + registry=self.server_registry, + ) + self.minecraft_version = Info( + name="Minecraft_Version", + documentation="The version of the minecraft of this server", + labelnames=["server_id"], + registry=self.server_registry, + ) + + self.online_players = Gauge( + name="online_players", + documentation="The number of players online for a server", + 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 acdc1cac..0402c587 100644 --- a/app/classes/shared/tasks.py +++ b/app/classes/shared/tasks.py @@ -5,10 +5,10 @@ import threading import asyncio import datetime import json - +from zoneinfo import ZoneInfoNotFoundError from tzlocal import get_localzone -from tzlocal.utils import ZoneInfoNotFoundError from apscheduler.events import EVENT_JOB_EXECUTED +from apscheduler.jobstores.base import JobLookupError from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger @@ -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,10 +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: @@ -101,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 @@ -324,11 +325,16 @@ class TasksManager: # Checks to make sure some doofus didn't actually make the newly # created task a child of itself. - if str(job_data["parent"]) == str(sch_id): + if ( + str(job_data["parent"]) == str(sch_id) + or job_data["interval_type"] != "reaction" + ): HelpersManagement.update_scheduled_task(sch_id, {"parent": None}) # Check to see if it's enabled and is not a chain reaction. if job_data["enabled"] and job_data["interval_type"] != "reaction": + # Lets make sure this can not be mistaken for a reaction + job_data["parent"] = None new_job = "error" if job_data["cron_string"] != "": try: @@ -449,7 +455,8 @@ class TasksManager: def update_job(self, sch_id, job_data): # Checks to make sure some doofus didn't actually make the newly # created task a child of itself. - if str(job_data.get("parent")) == str(sch_id): + interval_type = job_data.get("interval_type") + if str(job_data.get("parent")) == str(sch_id) or interval_type != "reaction": job_data["parent"] = None HelpersManagement.update_scheduled_task(sch_id, job_data) @@ -466,13 +473,15 @@ class TasksManager: job_data = HelpersManagement.get_scheduled_task(sch_id) job_data["server_id"] = job_data["server_id"]["server_id"] else: - self.scheduler.remove_job(str(sch_id)) + job = HelpersManagement.get_scheduled_task(sch_id) + if job["interval_type"] != "reaction": + self.scheduler.remove_job(str(sch_id)) return try: if job_data["interval"] != "reaction": self.scheduler.remove_job(str(sch_id)) - except: + except JobLookupError: logger.info( "No job found in update job. " "Assuming it was previously disabled. Starting new job." @@ -608,7 +617,10 @@ class TasksManager: ): # event job ID's are strings so we need to look at # this as the same data type. - if str(schedule.parent) == str(event.job_id): + if ( + str(schedule.parent) == str(event.job_id) + and schedule.interval_type == "reaction" + ): if schedule.enabled: delaytime = datetime.datetime.now() + datetime.timedelta( seconds=schedule.delay @@ -688,10 +700,16 @@ class TasksManager: # Stats are different host_stats = HelpersManagement.get_latest_hosts_stats() - if len(self.helper.websocket_helper.clients) > 0: + + self.controller.management.cpu_usage.set(host_stats.get("cpu_usage")) + self.controller.management.mem_usage_percent.set( + host_stats.get("mem_percent") + ) + + if len(WebSocketManager().clients) > 0: # There are clients try: - self.helper.websocket_helper.broadcast_page( + WebSocketManager().broadcast_page( "/panel/dashboard", "update_host_stats", { @@ -708,7 +726,7 @@ class TasksManager: }, ) except: - self.helper.websocket_helper.broadcast_page( + WebSocketManager().broadcast_page( "/panel/dashboard", "update_host_stats", { @@ -726,12 +744,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: @@ -740,11 +767,13 @@ class TasksManager: ) # Search for old files in imports self.helper.ensure_dir_exists( - os.path.join(self.controller.project_root, "imports") + os.path.join(self.controller.project_root, "import", "upload") ) - for file in os.listdir(os.path.join(self.controller.project_root, "imports")): + for file in os.listdir( + os.path.join(self.controller.project_root, "import", "upload") + ): if self.helper.is_file_older_than_x_days( - os.path.join(self.controller.project_root, "imports", file) + os.path.join(self.controller.project_root, "import", "upload", file) ): try: os.remove(os.path.join(file)) 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 efe8d2fa..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 bleach -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 = bleach.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 = bleach.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""" - + +    +    + {% end %} @@ -274,6 +280,12 @@