diff --git a/README.md b/README.md new file mode 100644 index 00000000..666efb42 --- /dev/null +++ b/README.md @@ -0,0 +1,18 @@ +# Crafty Controller 4.0.0-alpha.2 +> Python based Control Panel for your Minecraft Server + +## What is Crafty Controller? +Crafty Controller is a Minecraft Server Control Panel / Launcher. The purpose +of Crafty Controller is to launch a Minecraft Server in the background and present +a web interface for the server administrators to interact with their servers. Crafty +is compatible with Docker, Linux, Windows 7, Windows 8 and Windows 10. + +## Documentation +Temporary documentation available on [GitLab](https://gitlab.com/crafty-controller/crafty-commander/wikis/home) + +## Meta +Project Homepage - https://craftycontrol.com + +Discord Server - https://discord.gg/9VJPhCE + +Git Repository - https://gitlab.com/crafty-controller/crafty-web \ No newline at end of file diff --git a/app/classes/minecraft/serverjars.py b/app/classes/minecraft/serverjars.py index dc76bffa..1006ab2c 100644 --- a/app/classes/minecraft/serverjars.py +++ b/app/classes/minecraft/serverjars.py @@ -71,6 +71,29 @@ class ServerJars: data = self._read_cache() return data.get('servers') + def get_serverjar_data_sorted(self): + data = self.get_serverjar_data() + + def str_to_int(x, counter=0): + try: + return ord(x[0]) + str_to_int(x[1:], counter + 1) + len(x) + except IndexError: + return 0 + + def to_int(x): + try: + return int(x) + except ValueError: + temp = x.split('-') + return to_int(temp[0]) + str_to_int(temp[1]) / 100000 + + sort_key_fn = lambda x: [to_int(y) for y in x.split('.')] + + for key in data.keys(): + data[key] = sorted(data[key], key=sort_key_fn) + + return data + def _check_api_alive(self): logger.info("Checking serverjars.com API status") diff --git a/app/classes/shared/controller.py b/app/classes/shared/controller.py index ce021d37..26d0411f 100644 --- a/app/classes/shared/controller.py +++ b/app/classes/shared/controller.py @@ -316,7 +316,7 @@ class Controller: server_command = 'java -Xms{}M -Xmx{}M -jar {} nogui'.format(helper.float_to_string(min_mem), helper.float_to_string(max_mem), full_jar_path) - print('command: ' + server_command) + logger.debug('command: ' + server_command) server_log_file = "{}/logs/latest.log".format(new_server_dir) server_stop = "stop" diff --git a/app/classes/shared/helpers.py b/app/classes/shared/helpers.py index 5c3c8343..424d89ff 100644 --- a/app/classes/shared/helpers.py +++ b/app/classes/shared/helpers.py @@ -159,7 +159,7 @@ class Helpers: version = "{}.{}.{}-{}".format(version_data.get('major', '?'), version_data.get('minor', '?'), version_data.get('sub', '?'), - version_data.get('patch', '?')) + version_data.get('meta', '?')) return str(version) def do_exit(self): @@ -195,6 +195,9 @@ class Helpers: (r'(\[.+?/ERROR\])', r'\1'), (r'(\w+?\[/\d+?\.\d+?\.\d+?\.\d+?\:\d+?\])', r'\1'), (r'\[(\d\d:\d\d:\d\d)\]', r'[\1]'), + (r'(\[.+? INFO\])', r'\1'), + (r'(\[.+? WARN\])', r'\1'), + (r'(\[.+? ERROR\])', r'\1') ] # highlight users keywords @@ -590,4 +593,10 @@ class Helpers: return True + @staticmethod + def remove_prefix(text, prefix): + if text.startswith(prefix): + return text[len(prefix):] + return text + helper = Helpers() diff --git a/app/classes/shared/server.py b/app/classes/shared/server.py index 2266b256..e698226e 100644 --- a/app/classes/shared/server.py +++ b/app/classes/shared/server.py @@ -11,6 +11,7 @@ import schedule import logging.config import zipfile from threading import Thread +import html from app.classes.shared.helpers import helper @@ -29,6 +30,48 @@ except ModuleNotFoundError as e: console.critical("Import Error: Unable to load {} module".format(e.name)) sys.exit(1) +class ServerOutBuf: + lines = {} + def __init__(self, p, server_id): + self.p = p + self.server_id = str(server_id) + # Buffers text for virtual_terminal_lines config number of lines + self.max_lines = helper.get_setting('virtual_terminal_lines') + self.line_buffer = '' + ServerOutBuf.lines[self.server_id] = [] + + def check(self): + while self.p.isalive(): + char = self.p.read(1) + if char == os.linesep: + ServerOutBuf.lines[self.server_id].append(self.line_buffer) + self.new_line_handler(self.line_buffer) + self.line_buffer = '' + # Limit list length to self.max_lines: + if len(ServerOutBuf.lines[self.server_id]) > self.max_lines: + ServerOutBuf.lines[self.server_id].pop(0) + else: + self.line_buffer += char + + def new_line_handler(self, new_line): + new_line = re.sub('(\033\\[(0;)?[0-9]*[A-z]?(;[0-9])?m?)|(> )', '', new_line) + new_line = re.sub('[A-z]{2}\b\b', '', new_line) + highlighted = helper.log_colors(html.escape(new_line)) + + logger.debug('Broadcasting new virtual terminal line') + + # TODO: Do not send data to clients who do not have permission to view this server's console + websocket_helper.broadcast_page_params( + '/panel/server_detail', + { + 'id': self.server_id + }, + 'vterm_new_line', + { + 'line': highlighted + '
' + } + ) + class Server: @@ -88,7 +131,7 @@ class Server: def run_threaded_server(self): # start the server - self.server_thread = threading.Thread(target=self.start_server, daemon=True) + self.server_thread = threading.Thread(target=self.start_server, daemon=True, name='{}_server_thread'.format(self.server_id)) self.server_thread.start() def setup_server_run_command(self): @@ -137,6 +180,7 @@ class Server: logger.info("Linux Detected") logger.info("Starting server in {p} with command: {c}".format(p=self.server_path, c=self.server_command)) +<<<<<<< app/classes/shared/server.py try: self.process = pexpect.spawn(self.server_command, cwd=self.server_path, timeout=None, encoding=None) except Exception as ex: @@ -148,6 +192,15 @@ class Server: return False websocket_helper.broadcast('send_start_reload', { }) +======= + + self.process = pexpect.spawn(self.server_command, cwd=self.server_path, timeout=None, encoding='utf-8') + out_buf = ServerOutBuf(self.process, self.server_id) + + logger.debug('Starting virtual terminal listener for server {}'.format(self.name)) + threading.Thread(target=out_buf.check, daemon=True, name='{}_virtual_terminal'.format(self.server_id)).start() + +>>>>>>> app/classes/shared/server.py self.is_crashed = False self.start_time = str(datetime.datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')) diff --git a/app/classes/shared/tasks.py b/app/classes/shared/tasks.py index 20b0ffa4..572918db 100644 --- a/app/classes/shared/tasks.py +++ b/app/classes/shared/tasks.py @@ -210,7 +210,7 @@ class TasksManager: host_stats = db_helper.get_latest_hosts_stats() if len(websocket_helper.clients) > 0: # There are clients - websocket_helper.broadcast('update_host_stats', { + websocket_helper.broadcast_page('/panel/dashboard', 'update_host_stats', { 'cpu_usage': host_stats.get('cpu_usage'), 'cpu_cores': host_stats.get('cpu_cores'), 'cpu_cur_freq': host_stats.get('cpu_cur_freq'), @@ -218,13 +218,9 @@ class TasksManager: 'mem_percent': host_stats.get('mem_percent'), 'mem_usage': host_stats.get('mem_usage') }) - time.sleep(4) - else: - # Stats are same - time.sleep(8) + time.sleep(4) def log_watcher(self): - console.debug('in log_watcher') helper.check_for_old_logs(db_helper) schedule.every(6).hours.do(lambda: helper.check_for_old_logs(db_helper)).tag('log-mgmt') diff --git a/app/classes/shared/translation.py b/app/classes/shared/translation.py index 5809e592..8099ac09 100644 --- a/app/classes/shared/translation.py +++ b/app/classes/shared/translation.py @@ -13,19 +13,19 @@ class Translation(): self.translations_path = os.path.join(helper.root_dir, 'app', 'translations') self.cached_translation = None self.cached_translation_lang = None + self.lang_file_exists = [] def translate(self, page, word): translated_word = None lang = helper.get_setting('language') fallback_lang = 'en_EN' - lang_file_exists = helper.check_file_exists( - os.path.join( - self.translations_path, lang + '.json' - ) - ) + if lang not in self.lang_file_exists and \ + helper.check_file_exists(os.path.join(self.translations_path, lang + '.json')): + self.lang_file_exists.append(lang) + translated_word = self.translate_inner(page, word, lang) \ - if lang_file_exists else self.translate_inner(page, word, fallback_lang) + if lang in self.lang_file_exists else self.translate_inner(page, word, fallback_lang) if translated_word: if isinstance(translated_word, dict): return json.dumps(translated_word) diff --git a/app/classes/web/ajax_handler.py b/app/classes/web/ajax_handler.py index 700994b6..bca9397e 100644 --- a/app/classes/web/ajax_handler.py +++ b/app/classes/web/ajax_handler.py @@ -5,12 +5,15 @@ import tornado.escape import bleach import os import shutil +import html +import re from app.classes.shared.console import console from app.classes.shared.models import Users, installer from app.classes.web.base_handler import BaseHandler from app.classes.shared.models import db_helper from app.classes.shared.helpers import helper +from app.classes.shared.server import ServerOutBuf logger = logging.getLogger(__name__) @@ -47,7 +50,7 @@ class AjaxHandler(BaseHandler): 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 False + return server_id = bleach.clean(server_id) @@ -55,20 +58,23 @@ class AjaxHandler(BaseHandler): 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("Log path not found in server_log ajax call ({})".format(server_id)) if full_log: log_lines = helper.get_setting('max_log_lines') + data = helper.tail_file(server_data['log_path'], log_lines) else: - log_lines = helper.get_setting('virtual_terminal_lines') + data = ServerOutBuf.lines.get(server_id, []) - data = helper.tail_file(server_data['log_path'], log_lines) for d in data: try: - line = helper.log_colors(d) + d = re.sub('(\033\\[(0;)?[0-9]*[A-z]?(;[0-9])?m?)|(> )', '', d) + d = re.sub('[A-z]{2}\b\b', '', d) + line = helper.log_colors(html.escape(d)) self.write('{}
'.format(line)) # self.write(d.encode("utf-8")) @@ -85,14 +91,14 @@ class AjaxHandler(BaseHandler): file_path = self.get_argument('file_path', None) server_id = self.get_argument('id', None) - if not self.check_server_id(server_id, 'get_file'): return False + if not self.check_server_id(server_id, 'get_file'): return else: server_id = bleach.clean(server_id) if not helper.in_path(db_helper.get_server_data_by_id(server_id)['path'], file_path)\ or not helper.check_file_exists(os.path.abspath(file_path)): logger.warning("Invalid path in get_file ajax call ({})".format(file_path)) console.warning("Invalid path in get_file ajax call ({})".format(file_path)) - return False + return error = None @@ -113,7 +119,7 @@ class AjaxHandler(BaseHandler): elif page == "get_tree": server_id = self.get_argument('id', None) - if not self.check_server_id(server_id, 'get_tree'): return False + if not self.check_server_id(server_id, 'get_tree'): return else: server_id = bleach.clean(server_id) self.write(db_helper.get_server_data_by_id(server_id)['path'] + '\n' + @@ -149,16 +155,15 @@ class AjaxHandler(BaseHandler): file_name = self.get_body_argument('file_name', default=None, strip=True) file_path = os.path.join(file_parent, file_name) server_id = self.get_argument('id', None) - print(server_id) - if not self.check_server_id(server_id, 'create_file'): return False + if not self.check_server_id(server_id, 'create_file'): return else: server_id = bleach.clean(server_id) if not helper.in_path(db_helper.get_server_data_by_id(server_id)['path'], file_path) \ or helper.check_file_exists(os.path.abspath(file_path)): logger.warning("Invalid path in create_file ajax call ({})".format(file_path)) console.warning("Invalid path in create_file ajax call ({})".format(file_path)) - return False + return # Create the file by opening it with open(file_path, 'w') as file_object: @@ -169,16 +174,15 @@ class AjaxHandler(BaseHandler): dir_name = self.get_body_argument('dir_name', default=None, strip=True) dir_path = os.path.join(dir_parent, dir_name) server_id = self.get_argument('id', None) - print(server_id) - if not self.check_server_id(server_id, 'create_dir'): return False + if not self.check_server_id(server_id, 'create_dir'): return else: server_id = bleach.clean(server_id) if not helper.in_path(db_helper.get_server_data_by_id(server_id)['path'], dir_path) \ or helper.check_path_exists(os.path.abspath(dir_path)): logger.warning("Invalid path in create_dir ajax call ({})".format(dir_path)) console.warning("Invalid path in create_dir ajax call ({})".format(dir_path)) - return False + return # Create the directory os.mkdir(dir_path) @@ -191,7 +195,7 @@ class AjaxHandler(BaseHandler): console.warning("delete {} for server {}".format(file_path, server_id)) - if not self.check_server_id(server_id, 'del_file'): return False + if not self.check_server_id(server_id, 'del_file'): return else: server_id = bleach.clean(server_id) server_info = db_helper.get_server_data_by_id(server_id) @@ -200,7 +204,7 @@ class AjaxHandler(BaseHandler): or not helper.check_file_exists(os.path.abspath(file_path)): logger.warning("Invalid path in del_file ajax call ({})".format(file_path)) console.warning("Invalid path in del_file ajax call ({})".format(file_path)) - return False + return # Delete the file os.remove(file_path) @@ -208,11 +212,10 @@ class AjaxHandler(BaseHandler): elif page == "del_dir": dir_path = self.get_body_argument('dir_path', default=None, strip=True) server_id = self.get_argument('id', None) - print(server_id) console.warning("delete {} for server {}".format(dir_path, server_id)) - if not self.check_server_id(server_id, 'del_dir'): return False + if not self.check_server_id(server_id, 'del_dir'): return else: server_id = bleach.clean(server_id) server_info = db_helper.get_server_data_by_id(server_id) @@ -220,7 +223,7 @@ class AjaxHandler(BaseHandler): or not helper.check_path_exists(os.path.abspath(dir_path)): logger.warning("Invalid path in del_file ajax call ({})".format(dir_path)) console.warning("Invalid path in del_file ajax call ({})".format(dir_path)) - return False + return # Delete the directory # os.rmdir(dir_path) # Would only remove empty directories @@ -232,18 +235,15 @@ class AjaxHandler(BaseHandler): file_contents = self.get_body_argument('file_contents', default=None, strip=True) file_path = self.get_body_argument('file_path', default=None, strip=True) server_id = self.get_argument('id', None) - print(file_contents) - print(file_path) - print(server_id) - if not self.check_server_id(server_id, 'save_file'): return False + if not self.check_server_id(server_id, 'save_file'): return else: server_id = bleach.clean(server_id) if not helper.in_path(db_helper.get_server_data_by_id(server_id)['path'], file_path)\ or not helper.check_file_exists(os.path.abspath(file_path)): logger.warning("Invalid path in save_file ajax call ({})".format(file_path)) console.warning("Invalid path in save_file ajax call ({})".format(file_path)) - return False + return # Open the file in write mode and store the content in file_object with open(file_path, 'w') as file_object: @@ -253,21 +253,20 @@ class AjaxHandler(BaseHandler): item_path = self.get_body_argument('item_path', default=None, strip=True) new_item_name = self.get_body_argument('new_item_name', default=None, strip=True) server_id = self.get_argument('id', None) - print(server_id) - if not self.check_server_id(server_id, 'rename_item'): return False + if not self.check_server_id(server_id, 'rename_item'): return else: server_id = bleach.clean(server_id) if item_path is None or new_item_name is None: logger.warning("Invalid path(s) in rename_item ajax call") console.warning("Invalid path(s) in rename_item ajax call") - return False + return if not helper.in_path(db_helper.get_server_data_by_id(server_id)['path'], item_path) \ or not helper.check_path_exists(os.path.abspath(item_path)): logger.warning("Invalid old name path in rename_item ajax call ({})".format(server_id)) console.warning("Invalid old name path in rename_item ajax call ({})".format(server_id)) - return False + return new_item_path = os.path.join(os.path.split(item_path)[0], new_item_name) @@ -275,7 +274,7 @@ class AjaxHandler(BaseHandler): or helper.check_path_exists(os.path.abspath(new_item_path)): logger.warning("Invalid new name path in rename_item ajax call ({})".format(server_id)) console.warning("Invalid new name path in rename_item ajax call ({})".format(server_id)) - return False + return # RENAME os.rename(item_path, new_item_path) @@ -283,7 +282,7 @@ class AjaxHandler(BaseHandler): if server_id is None: logger.warning("Server ID not defined in {} ajax call ({})".format(page_name, server_id)) console.warning("Server ID not defined in {} ajax call ({})".format(page_name, server_id)) - return False + return else: server_id = bleach.clean(server_id) @@ -291,5 +290,5 @@ class AjaxHandler(BaseHandler): if not db_helper.server_id_exists(server_id): logger.warning("Server ID not found in {} ajax call ({})".format(page_name, server_id)) console.warning("Server ID not found in {} ajax call ({})".format(page_name, server_id)) - return False + return return True diff --git a/app/classes/web/api_handler.py b/app/classes/web/api_handler.py index 933b6235..50035c9f 100644 --- a/app/classes/web/api_handler.py +++ b/app/classes/web/api_handler.py @@ -41,11 +41,11 @@ class ApiHandler(BaseHandler): else: logging.debug("Auth unsuccessful") self.access_denied("unknown", "the user provided an invalid token") - return False + return except Exception as e: log.warning("An error occured while authenticating an API user: %s", e) self.access_denied("unknown"), "an error occured while authenticating the user" - return False + return class ServersStats(ApiHandler): diff --git a/app/classes/web/panel_handler.py b/app/classes/web/panel_handler.py index ebb2dfd9..bbe9ef7d 100644 --- a/app/classes/web/panel_handler.py +++ b/app/classes/web/panel_handler.py @@ -201,7 +201,7 @@ class PanelHandler(BaseHandler): #if not db_helper.server_id_authorized(server_id, exec_user_id): if not db_helper.server_id_authorized_from_roles(int(server_id), exec_user_id): self.redirect("/panel/error?error=Invalid Server ID") - return False + return server_info = db_helper.get_server_data_by_id(server_id) backup_file = os.path.abspath(os.path.join(server_info["backup_path"], file)) @@ -250,7 +250,7 @@ class PanelHandler(BaseHandler): #if not db_helper.server_id_authorized(server_id, exec_user_id): if not db_helper.server_id_authorized_from_roles(int(server_id), exec_user_id): self.redirect("/panel/error?error=Invalid Server ID") - return False + return server = self.controller.get_server_obj(server_id).backup_server() self.redirect("/panel/server_detail?id={}&subpage=backup".format(server_id)) diff --git a/app/classes/web/public_handler.py b/app/classes/web/public_handler.py index 3e9913db..4388f909 100644 --- a/app/classes/web/public_handler.py +++ b/app/classes/web/public_handler.py @@ -37,9 +37,6 @@ class PublicHandler(BaseHandler): def get(self, page=None): - self.clear_cookie("user") - self.clear_cookie("user_data") - error = bleach.clean(self.get_argument('error', "Invalid Login!")) page_data = { @@ -59,9 +56,16 @@ class PublicHandler(BaseHandler): elif page == "error": template = "public/error.html" + elif page == "logout": + self.clear_cookie("user") + self.clear_cookie("user_data") + self.redirect('/public/login') + return + # if we have no page, let's go to login else: self.redirect('/public/login') + return self.render( template, @@ -82,14 +86,18 @@ class PublicHandler(BaseHandler): # if we don't have a user if not user_data: next_page = "/public/error?error=Login Failed" + self.clear_cookie("user") + self.clear_cookie("user_data") self.redirect(next_page) - return False + return # if they are disabled if not user_data.enabled: next_page = "/public/error?error=Login Failed" + self.clear_cookie("user") + self.clear_cookie("user_data") self.redirect(next_page) - return False + return login_result = helper.verify_pass(entered_password, user_data.password) @@ -118,6 +126,8 @@ class PublicHandler(BaseHandler): next_page = "/panel/dashboard" self.redirect(next_page) else: + self.clear_cookie("user") + self.clear_cookie("user_data") # log this failed login attempt db_helper.add_to_audit_log(user_data.user_id, "Tried to log in", 0, self.get_remote_ip()) self.redirect('/public/error?error=Login Failed') diff --git a/app/classes/web/server_handler.py b/app/classes/web/server_handler.py index bbdbbdae..1f7dea57 100644 --- a/app/classes/web/server_handler.py +++ b/app/classes/web/server_handler.py @@ -61,7 +61,7 @@ class ServerHandler(BaseHandler): if page == "step1": - page_data['server_types'] = server_jar_obj.get_serverjar_data() + page_data['server_types'] = server_jar_obj.get_serverjar_data_sorted() template = "server/wizard.html" self.render( @@ -94,7 +94,7 @@ class ServerHandler(BaseHandler): for server in db_helper.get_all_defined_servers(): if server['server_name'] == name: return True - return False + return server_data = db_helper.get_server_data_by_id(server_id) server_uuid = server_data.get('server_uuid') @@ -105,8 +105,6 @@ class ServerHandler(BaseHandler): name_counter += 1 new_server_name = server_data.get('server_name') + " (Copy {})".format(name_counter) - console.debug('new_server_name: "{}"'.format(new_server_name)) - new_server_uuid = helper.create_uuid() while os.path.exists(os.path.join(helper.servers_dir, new_server_uuid)): new_server_uuid = helper.create_uuid() @@ -143,7 +141,6 @@ class ServerHandler(BaseHandler): }).execute() self.controller.init_all_servers() - console.debug('initted all servers') return @@ -163,14 +160,14 @@ class ServerHandler(BaseHandler): if not server_name: self.redirect("/panel/error?error=Server name cannot be empty!") - return False + return if import_type == 'import_jar': good_path = self.controller.verify_jar_server(import_server_path, import_server_jar) if not good_path: self.redirect("/panel/error?error=Server path or Server Jar not found!") - return False + return new_server_id = self.controller.import_jar_server(server_name, import_server_path,import_server_jar, min_mem, max_mem, port) db_helper.add_to_audit_log(exec_user_data['user_id'], @@ -182,12 +179,12 @@ class ServerHandler(BaseHandler): good_path = self.controller.verify_zip_server(import_server_path) if not good_path: self.redirect("/panel/error?error=Zip file not found!") - return False + return new_server_id = self.controller.import_zip_server(server_name, import_server_path,import_server_jar, min_mem, max_mem, port) if new_server_id == "false": self.redirect("/panel/error?error=Zip file not accessible! You can fix this permissions issue with sudo chown -R crafty:crafty {} And sudo chmod 2775 -R {}".format(import_server_path, import_server_path)) - return False + return db_helper.add_to_audit_log(exec_user_data['user_id'], "imported a zip server named \"{}\"".format(server_name), # Example: Admin imported a server named "old creative" new_server_id, @@ -195,7 +192,7 @@ class ServerHandler(BaseHandler): else: if len(server_parts) != 2: self.redirect("/panel/error?error=Invalid server data") - return False + return server_type, server_version = server_parts # todo: add server type check here and call the correct server add functions if not a jar new_server_id = self.controller.create_jar_server(server_type, server_version, server_name, min_mem, max_mem, port) diff --git a/app/classes/web/tornado.py b/app/classes/web/tornado.py index ced565ea..e3f51619 100644 --- a/app/classes/web/tornado.py +++ b/app/classes/web/tornado.py @@ -159,7 +159,7 @@ class Webserver: console.info("Server Init Complete: Listening For Connections:") - self.ioloop = tornado.ioloop.IOLoop.instance() + self.ioloop = tornado.ioloop.IOLoop.current() self.ioloop.start() def stop_web_server(self): diff --git a/app/classes/web/websocket_handler.py b/app/classes/web/websocket_handler.py index 107acdc5..ad98c2ab 100644 --- a/app/classes/web/websocket_handler.py +++ b/app/classes/web/websocket_handler.py @@ -1,13 +1,21 @@ import json import logging +import asyncio -import tornado.websocket -from app.classes.shared.console import console +from urllib.parse import parse_qsl from app.classes.shared.models import Users, db_helper +from app.classes.shared.helpers import helper from app.classes.web.websocket_helper import websocket_helper logger = logging.getLogger(__name__) +try: + import tornado.websocket + +except ModuleNotFoundError as e: + logger.critical("Import Error: Unable to load {} module".format(e, e.name)) + console.critical("Import Error: Unable to load {} module".format(e, e.name)) + sys.exit(1) class SocketHandler(tornado.websocket.WebSocketHandler): @@ -15,6 +23,7 @@ class SocketHandler(tornado.websocket.WebSocketHandler): self.controller = controller self.tasks_manager = tasks_manager self.translator = translator + self.io_loop = tornado.ioloop.IOLoop.current() def get_remote_ip(self): remote_ip = self.request.headers.get("X-Real-IP") or \ @@ -35,6 +44,7 @@ class SocketHandler(tornado.websocket.WebSocketHandler): def open(self): + logger.debug('Checking WebSocket authentication') if self.check_auth(): self.handle() else: @@ -42,10 +52,15 @@ class SocketHandler(tornado.websocket.WebSocketHandler): self.close() db_helper.add_to_audit_log_raw('unknown', 0, 0, 'Someone tried to connect via WebSocket without proper authentication', self.get_remote_ip()) websocket_helper.broadcast('notification', 'Someone tried to connect via WebSocket without proper authentication') + logger.warning('Someone tried to connect via WebSocket without proper authentication') def handle(self): - - websocket_helper.addClient(self) + self.page = self.get_query_argument('page') + self.page_query_params = dict(parse_qsl(helper.remove_prefix( + self.get_query_argument('page_query_params'), + '?' + ))) + websocket_helper.add_client(self) logger.debug('Opened WebSocket connection') # websocket_helper.broadcast('notification', 'New client connected') @@ -56,7 +71,13 @@ class SocketHandler(tornado.websocket.WebSocketHandler): logger.debug('Event Type: {}, Data: {}'.format(message['event'], message['data'])) def on_close(self): - websocket_helper.removeClient(self) + websocket_helper.remove_client(self) logger.debug('Closed WebSocket connection') # websocket_helper.broadcast('notification', 'Client disconnected') + async def write_message_int(self, message): + self.write_message(message) + + def write_message_helper(self, message): + asyncio.run_coroutine_threadsafe(self.write_message_int(message), self.io_loop.asyncio_loop) + diff --git a/app/classes/web/websocket_helper.py b/app/classes/web/websocket_helper.py index 37a85cb4..99935ad3 100644 --- a/app/classes/web/websocket_helper.py +++ b/app/classes/web/websocket_helper.py @@ -1,31 +1,75 @@ import json import logging +import sys, threading, asyncio from app.classes.shared.console import console logger = logging.getLogger(__name__) -class WebSocketHelper: - clients = set() - def addClient(self, client): +try: + import tornado.ioloop + +except ModuleNotFoundError as e: + logger.critical("Import Error: Unable to load {} module".format(e, e.name)) + console.critical("Import Error: Unable to load {} module".format(e, e.name)) + sys.exit(1) + +class WebSocketHelper: + def __init__(self): + self.clients = set() + + def add_client(self, client): self.clients.add(client) - def removeClient(self, client): - self.clients.add(client) - - def send_message(self, client, event_type, data): + 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(message) + client.write_message_helper(message) - def broadcast(self, event_type, data): - logger.debug('Sending: ' + str(json.dumps({'event': event_type, 'data': data}))) + def broadcast(self, event_type: str, data): + logger.debug('Sending to {} clients: {}'.format(len(self.clients), json.dumps({'event': event_type, 'data': data}))) for client in self.clients: try: self.send_message(client, event_type, data) - except: - pass + except Exception as e: + logger.exception('Error catched while sending WebSocket message to {}'.format(client.get_remote_ip())) + + def broadcast_page(self, page: str, event_type: str, data): + def filter_fn(client): + return client.page == page + + clients = list(filter(filter_fn, self.clients)) + + logger.debug('Sending to {} out of {} clients: {}'.format(len(clients), len(self.clients), json.dumps({'event': event_type, 'data': data}))) + + for client in clients: + try: + self.send_message(client, event_type, data) + except Exception as e: + logger.exception('Error catched while sending WebSocket message to {}'.format(client.get_remote_ip())) + + def broadcast_page_params(self, page: str, params: dict, event_type: str, data): + def filter_fn(client): + if client.page != page: + return False + for key, param in params.items(): + if param != client.page_query_params.get(key, None): + return False + return True + + clients = list(filter(filter_fn, self.clients)) + + logger.debug('Sending to {} out of {} clients: {}'.format(len(clients), len(self.clients), json.dumps({'event': event_type, 'data': data}))) + + for client in clients: + try: + self.send_message(client, event_type, data) + except Exception as e: + logger.exception('Error catched while sending WebSocket message to {}'.format(client.get_remote_ip())) def disconnect_all(self): console.info('Disconnecting WebSocket clients') diff --git a/app/config/config.json b/app/config/config.json index f6b866f3..27f08bb8 100644 --- a/app/config/config.json +++ b/app/config/config.json @@ -10,7 +10,7 @@ "stats_update_frequency": 30, "delete_default_json": false, "show_contribute_link": true, - "virtual_terminal_lines": 10, + "virtual_terminal_lines": 70, "max_log_lines": 700, "keywords": ["help", "chunk"] -} \ No newline at end of file +} diff --git a/app/config/logging.json b/app/config/logging.json index 8c524b01..c6ccd7b1 100644 --- a/app/config/logging.json +++ b/app/config/logging.json @@ -8,7 +8,7 @@ "tornado_access": { "format": "%(asctime)s - [Tornado] - [Access] - %(levelname)s - %(message)s" }, - "schedule": { + "schedule": { "format": "%(asctime)s - [Schedules] - %(levelname)s - %(message)s" } }, diff --git a/app/frontend/templates/base.html b/app/frontend/templates/base.html index 1a8e9bc1..e414341c 100644 --- a/app/frontend/templates/base.html +++ b/app/frontend/templates/base.html @@ -173,8 +173,9 @@ let listenEvents = []; try { - - var wsInternal = new WebSocket('wss://' + location.host + '/ws'); + pageQueryParams = 'page_query_params=' + encodeURIComponent(location.search) + page = 'page=' + encodeURIComponent(location.pathname) + var wsInternal = new WebSocket('wss://' + location.host + '/ws?' + page + '&' + pageQueryParams); wsInternal.onopen = function() { console.log('opened WebSocket connection:', wsInternal) }; diff --git a/app/frontend/templates/notify.html b/app/frontend/templates/notify.html index 09e6c7da..5cafcacd 100644 --- a/app/frontend/templates/notify.html +++ b/app/frontend/templates/notify.html @@ -29,7 +29,7 @@ {% end %} Activity - Sign Out + Sign Out \ No newline at end of file diff --git a/app/frontend/templates/panel/server_files.html b/app/frontend/templates/panel/server_files.html index de1cc745..fe8501f9 100644 --- a/app/frontend/templates/panel/server_files.html +++ b/app/frontend/templates/panel/server_files.html @@ -166,8 +166,9 @@ margin-left: 10px; } - /* Style the caret/arrow */ - .tree-caret { + /* Style the items */ + .tree-item, + .files-tree-title { cursor: pointer; user-select: none; /* Prevent text selection */ } diff --git a/app/frontend/templates/panel/server_term.html b/app/frontend/templates/panel/server_term.html index 68e75b34..4fbeee8b 100644 --- a/app/frontend/templates/panel/server_term.html +++ b/app/frontend/templates/panel/server_term.html @@ -65,7 +65,7 @@ @@ -169,7 +169,6 @@ let server_id = '{{ data['server_stats']['server_id']['server_id'] }}'; function get_server_log(){ - if( !$("#stop_scroll").is(':checked')){ $.ajax({ type: 'GET', url: '/ajax/server_log?id={{ data['server_stats']['server_id']['server_id'] }}', @@ -177,10 +176,16 @@ success: function (data) { console.log('Got Log From Server') $('#virt_console').html(data); - scroll(); - }, + scrollConsole(); + }, }); - } + } + + function new_line_handler(data) { + $('#virt_console').append(data.line) + if (!$("#stop_scroll").is(':checked')) { + scrollConsole() + } } //used to get cookies from browser - this is part of tornados xsrf protection - it's for extra security @@ -193,9 +198,7 @@ console.log( "ready!" ); get_server_log() - setInterval(function(){ - get_server_log() // this will run after every 5 seconds - }, 1500); + webSocket.on('vterm_new_line', new_line_handler) }); $('#server_command').on('keydown', function (e) { @@ -218,7 +221,7 @@ }); - function scroll(){ + function scrollConsole(){ var logview = $('#virt_console'); if(logview.length) logview.scrollTop(logview[0].scrollHeight - logview.height()); diff --git a/app/translations/en_EN.json b/app/translations/en_EN.json index 1eb6b99f..fc00c54c 100644 --- a/app/translations/en_EN.json +++ b/app/translations/en_EN.json @@ -8,7 +8,7 @@ "error": { "hereIsTheError": "Here is the error", "contact": "Contact Crafty Control Support via Discord", - "terribleFailure": "What a Terrble Failure!", + "terribleFailure": "What a Terrible Failure!", "embarassing": "Oh my, well, this is embarrassing.", "error": "Error!" }, @@ -120,7 +120,7 @@ "playerControls": "Player Management" }, "serverTerm": { - "stopRefresh": "Stop Refresh", + "stopScroll": "Stop Auto Scrollling", "commandInput": "Enter your command", "sendCommand": "Send command", "start": "Start", diff --git a/app/translations/fi_FI.json b/app/translations/fi_FI.json index c13182fa..a9a34a0e 100644 --- a/app/translations/fi_FI.json +++ b/app/translations/fi_FI.json @@ -120,7 +120,7 @@ "playerControls": "Pelaajahallinta" }, "serverTerm": { - "stopRefresh": "Lopeta päivitys", + "stopScroll": "Lopeta automaattinen vieritys", "commandInput": "Kirjoita komento", "sendCommand": "Lähetä komento", "start": "Käynnistä", diff --git a/main.py b/main.py index 2ca05097..3b09efab 100644 --- a/main.py +++ b/main.py @@ -4,6 +4,7 @@ import json import time import argparse import logging.config +import signal """ Our custom classes / pip packages """ from app.classes.shared.console import console @@ -130,9 +131,24 @@ if __name__ == '__main__': # this should always be last tasks_manager.start_main_kill_switch_watcher() - Crafty = MainPrompt(tasks_manager, migration_manager) + Crafty = MainPrompt(tasks_manager) + + def sigterm_handler(signum, current_stack_frame): + print() # for newline + logger.info("Recieved SIGTERM, stopping Crafty") + console.info("Recieved SIGTERM, stopping Crafty") + Crafty.universal_exit() + + signal.signal(signal.SIGTERM, sigterm_handler) + if not args.daemon: - Crafty.cmdloop() + try: + Crafty.cmdloop() + except KeyboardInterrupt: + print() # for newline + logger.info("Recieved SIGINT, stopping Crafty") + console.info("Recieved SIGINT, stopping Crafty") + Crafty.universal_exit() else: print("Crafty started in daemon mode, no shell will be printed") while True: @@ -142,6 +158,7 @@ if __name__ == '__main__': time.sleep(1) except KeyboardInterrupt: logger.info("Recieved SIGINT, stopping Crafty") + console.info("Recieved SIGINT, stopping Crafty") break - + Crafty.universal_exit()