From 22142e36ec0c545df48744ebe74e17a29dd12238 Mon Sep 17 00:00:00 2001 From: Antoine <91411073+T0ine34@users.noreply.github.com> Date: Wed, 22 Oct 2025 18:22:37 +0200 Subject: [PATCH 1/8] init: add Python web-client backend (HTTP & WebSocket servers), utils and packaging --- .gitignore | 4 + pyproject.toml | 27 + src/modular_server_manager.egg-info/PKG-INFO | 25 + .../SOURCES.txt | 8 + .../dependency_links.txt | 1 + .../requires.txt | 17 + .../top_level.txt | 3 + web_server/__init__.py | 1 + web_server/http_server.py | 556 ++++++++++++++++++ web_server/utils.py | 48 ++ web_server/web_server.py | 159 +++++ web_server/websocket_server.py | 45 ++ 12 files changed, 894 insertions(+) create mode 100644 pyproject.toml create mode 100644 src/modular_server_manager.egg-info/PKG-INFO create mode 100644 src/modular_server_manager.egg-info/SOURCES.txt create mode 100644 src/modular_server_manager.egg-info/dependency_links.txt create mode 100644 src/modular_server_manager.egg-info/requires.txt create mode 100644 src/modular_server_manager.egg-info/top_level.txt create mode 100644 web_server/__init__.py create mode 100644 web_server/http_server.py create mode 100644 web_server/utils.py create mode 100644 web_server/web_server.py create mode 100644 web_server/websocket_server.py diff --git a/.gitignore b/.gitignore index 0b1157f..62ea235 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ # Node deps node_modules/ +# Python deps +env/ +__pycache__/ + # Build output dist/ src/assets/ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..af79110 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,27 @@ +[project] +name = "modular-server-manager" +version = "0.1.3" +description = "Modular Server Manager" +authors = [ + { name = "Antoine BUIREY", email = "antoine.buirey@gmail.com" } +] +requires-python = ">=3.12" +dependencies = [ + "blinker==1.9.0", + "click==8.1.8", + "Flask==3.1.1", + "gamuLogger>=3.2.4", + "itsdangerous==2.2.0", + "Jinja2==3.1.6", + "MarkupSafe==3.0.2", + "typing_extensions==4.13.2", + "urllib3==2.5.0", + "Werkzeug==3.1.3", + "dnspython==2.7.0", + "eventlet==0.40.3", + "greenlet==3.2.1", + "python-socketio==5.14.0", + "http_code @ https://github.com/T0ine34/python-sample/releases/download/1.1.6/http_code-1.1.6-py3-none-any.whl", + "singleton @ https://github.com/T0ine34/python-sample/releases/download/1.1.6/singleton-1.1.6-py3-none-any.whl", + "version @ https://github.com/T0ine34/python-sample/releases/download/1.1.6/version-1.1.6-py3-none-any.whl", +] diff --git a/src/modular_server_manager.egg-info/PKG-INFO b/src/modular_server_manager.egg-info/PKG-INFO new file mode 100644 index 0000000..a00d550 --- /dev/null +++ b/src/modular_server_manager.egg-info/PKG-INFO @@ -0,0 +1,25 @@ +Metadata-Version: 2.4 +Name: modular-server-manager +Version: 0.1.3 +Summary: Modular Server Manager +Author-email: Antoine BUIREY +Requires-Python: >=3.12 +License-File: LICENSE +Requires-Dist: blinker==1.9.0 +Requires-Dist: click==8.1.8 +Requires-Dist: Flask==3.1.1 +Requires-Dist: gamuLogger>=3.2.4 +Requires-Dist: itsdangerous==2.2.0 +Requires-Dist: Jinja2==3.1.6 +Requires-Dist: MarkupSafe==3.0.2 +Requires-Dist: typing_extensions==4.13.2 +Requires-Dist: urllib3==2.5.0 +Requires-Dist: Werkzeug==3.1.3 +Requires-Dist: dnspython==2.7.0 +Requires-Dist: eventlet==0.40.3 +Requires-Dist: greenlet==3.2.1 +Requires-Dist: python-socketio==5.14.0 +Requires-Dist: http_code@ https://github.com/T0ine34/python-sample/releases/download/1.1.6/http_code-1.1.6-py3-none-any.whl +Requires-Dist: singleton@ https://github.com/T0ine34/python-sample/releases/download/1.1.6/singleton-1.1.6-py3-none-any.whl +Requires-Dist: version@ https://github.com/T0ine34/python-sample/releases/download/1.1.6/version-1.1.6-py3-none-any.whl +Dynamic: license-file diff --git a/src/modular_server_manager.egg-info/SOURCES.txt b/src/modular_server_manager.egg-info/SOURCES.txt new file mode 100644 index 0000000..0e42343 --- /dev/null +++ b/src/modular_server_manager.egg-info/SOURCES.txt @@ -0,0 +1,8 @@ +LICENSE +README.md +pyproject.toml +src/modular_server_manager.egg-info/PKG-INFO +src/modular_server_manager.egg-info/SOURCES.txt +src/modular_server_manager.egg-info/dependency_links.txt +src/modular_server_manager.egg-info/requires.txt +src/modular_server_manager.egg-info/top_level.txt \ No newline at end of file diff --git a/src/modular_server_manager.egg-info/dependency_links.txt b/src/modular_server_manager.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/modular_server_manager.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/src/modular_server_manager.egg-info/requires.txt b/src/modular_server_manager.egg-info/requires.txt new file mode 100644 index 0000000..8077d52 --- /dev/null +++ b/src/modular_server_manager.egg-info/requires.txt @@ -0,0 +1,17 @@ +blinker==1.9.0 +click==8.1.8 +Flask==3.1.1 +gamuLogger>=3.2.4 +itsdangerous==2.2.0 +Jinja2==3.1.6 +MarkupSafe==3.0.2 +typing_extensions==4.13.2 +urllib3==2.5.0 +Werkzeug==3.1.3 +dnspython==2.7.0 +eventlet==0.40.3 +greenlet==3.2.1 +python-socketio==5.14.0 +http_code@ https://github.com/T0ine34/python-sample/releases/download/1.1.6/http_code-1.1.6-py3-none-any.whl +singleton@ https://github.com/T0ine34/python-sample/releases/download/1.1.6/singleton-1.1.6-py3-none-any.whl +version@ https://github.com/T0ine34/python-sample/releases/download/1.1.6/version-1.1.6-py3-none-any.whl diff --git a/src/modular_server_manager.egg-info/top_level.txt b/src/modular_server_manager.egg-info/top_level.txt new file mode 100644 index 0000000..4236120 --- /dev/null +++ b/src/modular_server_manager.egg-info/top_level.txt @@ -0,0 +1,3 @@ +assets +scripts +styles diff --git a/web_server/__init__.py b/web_server/__init__.py new file mode 100644 index 0000000..9e017d6 --- /dev/null +++ b/web_server/__init__.py @@ -0,0 +1 @@ +from .web_server import WebServer diff --git a/web_server/http_server.py b/web_server/http_server.py new file mode 100644 index 0000000..5daa744 --- /dev/null +++ b/web_server/http_server.py @@ -0,0 +1,556 @@ +# pyright: reportUnusedFunction=false +# pyright: reportMissingTypeStubs=false + +import html +import os +import pathlib +import traceback +from typing import Any, Callable, TypeVar, Union + +from flask import Flask, request +from gamuLogger import Logger +from http_code import HttpCode as HTTP +from version import Version + +from .utils import str2bool, guess_type, RE_MC_SERVER_NAME +from ..Base_interface import BaseInterface # will be changed to be an external dependency +from ..database.types import AccessLevel # will be changed to be an external dependency + +Logger.set_module("User Interface.Http Server") + +STATIC_PATH = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + "/client" + +T = TypeVar('T') + +# type JsonAble = dict[str, JsonAble] | list[JsonAble] | str | int | float | bool | None +JsonAble = Union[dict[str, Any], list[Any], str, int, float, bool, None] + +FlaskReturnData = ( + tuple[JsonAble, int, dict[str, str]] | # data, status code, headers + tuple[JsonAble, int] | # data, status code + tuple[JsonAble] | # data + JsonAble | # data + + tuple[str, int, dict[str, str]] | # string, status code, headers + tuple[str, int] | # string, status code + tuple[str] | # string + str # string +) + +class HttpServer(BaseInterface): + def __init__(self, *args: Any, port: int, **kwargs: Any): + Logger.trace("Initializing HttpServer") + BaseInterface.__init__(self, *args, **kwargs) + self._port = port + self.__app = Flask(__name__) + + self.__config_api_route() + self.__config_static_route() + + def _get_app(self): + """ + Get the Flask app instance. + + :return: The Flask app instance. + """ + return self.__app + + def request_auth(self, access_level: AccessLevel) -> Callable[[T], T]: + """ + Decorator to check if the user has the required access level. + + Can expose the token, server name and user object to the function. + - token: the token used to authenticate the user + - server: the server name passed in the request + - user: the user object associated with the token + + **The type hints for the function must be set for the decorator to work properly.** + + :param access_level: Required access level. + """ + def decorator(f : Callable[[Any], FlaskReturnData] | Callable[[], FlaskReturnData]) -> Callable[[Any], FlaskReturnData] | Callable[[], FlaskReturnData]: + def wrapper(*args: Any, **kwargs: Any) -> FlaskReturnData: + Logger.info(f"Request from {request.remote_addr} with method {request.method} for path {request.path}") + try: + if 'Authorization' not in request.headers: + Logger.info("Missing Authorization header") + return {"message": "Missing parameters"}, HTTP.BAD_REQUEST + token = request.headers.get('Authorization') + if not token: + Logger.info("Missing Authorization header") + return {"message": "Missing parameters"}, HTTP.BAD_REQUEST + if not token.startswith("Bearer "): + Logger.info("Invalid Authorization header format") + return {"message": "Invalid token"}, HTTP.UNAUTHORIZED + token = token[7:] + if not token or not self._database.exist_user_token(token): + Logger.info("Invalid token") + return {"message": "Invalid token"}, HTTP.UNAUTHORIZED + + access_token = self._database.get_user_token_by_token(token) + if not access_token or not access_token.is_valid(): + Logger.info("Invalid token") + return {"message": "Invalid token"}, HTTP.UNAUTHORIZED + + user = self._database.get_user(access_token.username) + if user.access_level < access_level: + Logger.info(f"User {user.username} does not have the required access level") + return {"message": "Forbidden"}, HTTP.FORBIDDEN + except Exception as e: + Logger.error(f"Error processing request: {e}") + Logger.debug(f"Error details: {traceback.format_exc()}") + return {"message": "Internal Server Error"}, HTTP.INTERNAL_SERVER_ERROR + else: + Logger.info(f"User {user.username} has the required access level") + additional_args = {} + if "token" in f.__annotations__: + additional_args["token"] = token + if "user" in f.__annotations__: + additional_args["user"] = user + return f(*args, **kwargs, **additional_args) + + wrapper.__name__ = f.__name__ + return wrapper + + return decorator # type: ignore + + def __config_static_route(self): + self.__app.static_folder = STATIC_PATH + + @self.__app.route('/') + def root(): + # redirect to the app index + Logger.trace("asking for index, redirecting to /app/") + return "/redirecting to /app/", HTTP.PERMANENT_REDIRECT, {'Location': '/app/'} + + @self.__app.route('/app/') #pyright: ignore[reportArgumentType, reportUntypedFunctionDecorator] + def index(): + Logger.trace("asking for index.html, redirecting to /app/dashboard") + return static_proxy('dashboard') + + @self.__app.route('/app/') #pyright: ignore[reportArgumentType, reportUntypedFunctionDecorator] + def static_proxy(path : str): + try: + # Validate the path to prevent directory traversal attacks + if ".." in path or path.startswith("/"): + Logger.trace(f"Invalid path: {path}") + return "Invalid path", HTTP.BAD_REQUEST + + # send the file to the browser + Logger.trace(f"requesting {STATIC_PATH}/{path}") + # Normalize the path and ensure it is within STATIC_PATH + full_path = os.path.normpath(os.path.join(STATIC_PATH, path)) + if not full_path.startswith(STATIC_PATH): + Logger.trace(f"Invalid path traversal attempt: {path}") + return "Invalid path", HTTP.BAD_REQUEST + + if not os.path.exists(full_path): + if os.path.exists(f"{full_path}.html"): + full_path = f"{full_path}.html" + # elif full_path.endswith('.css') or full_path.endswith('.js'): + elif any(full_path.endswith(ext) for ext in ['.css', '.js', '.css.map']): + # /client/login.js -> /client/login/login.js + filename = '.'.join(os.path.basename(full_path).split('.')[:-1]) + full_path = os.path.join(os.path.dirname(full_path), filename, os.path.basename(full_path)) + if not os.path.exists(full_path): + Logger.trace(f"File not found: {full_path}") + return "File not found", HTTP.NOT_FOUND + else: + Logger.trace(f"File not found: {full_path}") + return "File not found", HTTP.NOT_FOUND + + if os.path.isdir(full_path): + # If the path is a directory, serve the index.html file inside it + index_file = os.path.join(full_path, 'index.html') + if os.path.exists(index_file): + full_path = index_file + else: + Logger.trace(f"Directory requested without index file: {full_path}") + return "Directory requested without index file", HTTP.BAD_REQUEST + Logger.trace(f"Serving file: {full_path}") + + content = pathlib.Path(full_path).read_bytes() + mimetype = guess_type(full_path) + # Only allow known-safe mimetypes + Logger.trace(f"Serving {STATIC_PATH}/{path} ({len(content)} bytes) with mimetype {mimetype})") + return content, HTTP.OK, {'Content-Type': mimetype} + except Exception as e: + Logger.error(f"Error serving file {path}: {e}") + return "Internal Server Error", HTTP.INTERNAL_SERVER_ERROR + + @self.__app.route('/') #pyright: ignore[reportArgumentType, reportUntypedFunctionDecorator] + def static_fallback(path: str): + """ + Fallback route for static files. + This will serve files from the static folder if they exist. + """ + Logger.trace(f"Fallback for static file: {path}") + return static_proxy(path) + + def __config_api_route(self): + self.__config_api_route_user() + self.__config_api_route_server() + + + +################################################################################################### +# SERVER RELATED ENDPOINTS +# region: server +################################################################################################### + def __config_api_route_server(self): + + @self.__app.route('/api/mc_versions', methods=['GET']) #pyright: ignore[reportArgumentType, reportUntypedFunctionDecorator] + @self.request_auth(AccessLevel.USER) + def list_mc_versions() -> FlaskReturnData: + Logger.trace(f"API request for path: {request.path}") + try: + versions = self.list_mc_versions() + return {"versions": [str(version) for version in versions]}, HTTP.OK + except Exception as e: + Logger.error(f"Error processing API request for path {request.path}: {e}") + Logger.debug(f"Error details: {traceback.format_exc()}") + return {"message": "Internal Server Error"}, HTTP.INTERNAL_SERVER_ERROR + + @self.__app.route('/api/forge_versions/', methods=['GET']) #pyright: ignore[reportArgumentType, reportUntypedFunctionDecorator] + @self.request_auth(AccessLevel.USER) + def list_forge_versions(mc_version: str) -> FlaskReturnData: + Logger.trace(f"API request for path: {request.path}") + try: + if not Version.is_valid_string(mc_version): + Logger.trace(f"Invalid mc_version: {mc_version}") + return {"message": "Invalid mc_version"}, HTTP.BAD_REQUEST + mc_version_v = Version.from_string(mc_version) + versions = self.list_forge_versions(mc_version_v) + return {"versions": [str(version) for version in versions]}, HTTP.OK + except Exception as e: + Logger.error(f"Error processing API request for path {request.path}: {e}") + Logger.debug(f"Error details: {traceback.format_exc()}") + return {"message": "Internal Server Error"}, HTTP.INTERNAL_SERVER_ERROR + + @self.__app.route('/api/servers', methods=['GET']) #pyright: ignore[reportArgumentType, reportUntypedFunctionDecorator] + @self.request_auth(AccessLevel.USER) + def list_servers(token : str) -> FlaskReturnData: + Logger.trace(f"API request for path: {request.path}") + try: + servers = self.list_servers() + result = [] + for server in servers: + for key, item in server.items(): + if isinstance(item, Version): + server[key] = str(item) + result.append(server) + return result + except ValueError as ve: + Logger.debug(f"Error processing API request for path {request.path}: {ve}") + return {"message": "Bad Request"}, HTTP.BAD_REQUEST + except Exception as e: + Logger.error(f"Error processing API request for path {request.path}: {e}") + Logger.debug(f"Error details: {traceback.format_exc()}") + return {"message": "Internal Server Error"}, HTTP.INTERNAL_SERVER_ERROR + + @self.__app.route('/api/server/', methods=['GET']) #pyright: ignore[reportArgumentType, reportUntypedFunctionDecorator] + @self.request_auth(AccessLevel.USER) + def get_server_info(server_name: str) -> FlaskReturnData: + Logger.trace(f"API request for path: {request.path}") + try: + info = self.get_server_info(server_name) + for key, item in info.items(): + if isinstance(item, Version): + info[key] = str(item) + return info, HTTP.OK + except ValueError as ve: + Logger.debug(f"Error processing API request for path {request.path}: {ve}") + return {"message": "Bad Request"}, HTTP.BAD_REQUEST + except Exception as e: + Logger.error(f"Error processing API request for path {request.path}: {e}") + Logger.debug(f"Error details: {traceback.format_exc()}") + return {"message": "Internal Server Error"}, HTTP.INTERNAL_SERVER_ERROR + + @self.__app.route('/api/list_mc_server_dirs', methods=['GET']) #pyright: ignore[reportArgumentType, reportUntypedFunctionDecorator] + @self.request_auth(AccessLevel.USER) + def list_mc_server_dirs(token : str) -> FlaskReturnData: + Logger.trace(f"API request for path: {request.path}") + try: + dirs = self.list_mc_server_dirs() + return {"dirs": dirs}, HTTP.OK + except Exception as e: + Logger.error(f"Error processing API request for path {request.path}: {e}") + Logger.debug(f"Error details: {traceback.format_exc()}") + return {"message": "Internal Server Error"}, HTTP.INTERNAL_SERVER_ERROR + + @self.__app.route('/api/create_server', methods=['POST']) #pyright: ignore[reportArgumentType, reportUntypedFunctionDecorator] + @self.request_auth(AccessLevel.OPERATOR) + def create_new_server() -> FlaskReturnData: + Logger.trace(f"API request for path: {request.path}") + try: + data : dict[str, JsonAble] = request.get_json() + server_name = data.get("name") + server_type = data.get("type") + server_path = data.get("path") + + autostart = data.get("autostart", False) + + mc_version = data.get("mc_version") + modloader_version = data.get("modloader_version") + ram = data.get("ram") + + if not server_name or not isinstance(server_name, str) or not RE_MC_SERVER_NAME.match(server_name): + Logger.debug("Invalid server name") + return {"message": "Invalid parameters"}, HTTP.BAD_REQUEST + server_name = html.escape(server_name.strip()) + if not server_type or not isinstance(server_type, str): + Logger.debug("Invalid server type") + return {"message": "Invalid parameters"}, HTTP.BAD_REQUEST + + if not server_path or not isinstance(server_path, str): + Logger.debug("Invalid server path") + return {"message": "Invalid parameters"}, HTTP.BAD_REQUEST + server_path = html.escape(server_path.strip()) + if not mc_version or not isinstance(mc_version, str): + Logger.debug("Invalid mc_version") + return {"message": "Invalid parameters"}, HTTP.BAD_REQUEST + mc_version = Version.from_string(mc_version) + if server_type != "vanilla" and not modloader_version or not isinstance(modloader_version, str): + Logger.debug("Invalid modloader_version") + return {"message": "Invalid parameters"}, HTTP.BAD_REQUEST + if not modloader_version: + Logger.debug("Modloader version is required for non-vanilla servers") + return {"message": "Invalid parameters"}, HTTP.BAD_REQUEST + modloader_version = Version.from_string(modloader_version) + if not isinstance(autostart, bool): + Logger.debug("Invalid autostart value") + return {"message": "Invalid parameters"}, HTTP.BAD_REQUEST + if not isinstance(ram, int) or ram <= 0: + Logger.debug("Invalid RAM value") + return {"message": "Invalid parameters"}, HTTP.BAD_REQUEST + + self.create_server( + name=server_name, + type=server_type, + path=server_path, + autostart=autostart, + mc_version=mc_version, + modloader_version=modloader_version, + ram=ram + ) + except ValueError as ve: + Logger.debug(f"Error creating server: {ve}") + return {"message": "Bad Request"}, HTTP.BAD_REQUEST + except Exception as e: + Logger.error(f"Error creating server: {e}") + Logger.debug(f"Error details: {traceback.format_exc()}") + return {"message": "Internal Server Error"}, HTTP.INTERNAL_SERVER_ERROR + + @self.__app.route('/api/start_server/', methods=['POST']) #pyright: ignore[reportArgumentType, reportUntypedFunctionDecorator] + @self.request_auth(AccessLevel.ADMIN) + def start_server(server_name: str) -> FlaskReturnData: + Logger.trace(f"API request for path: {request.path}") + try: + self.start_server(server_name) + return {"message": "Server started"}, HTTP.OK + except ValueError as ve: + Logger.debug(f"Start server error: {ve}") + return {"message": "Bad Request"}, HTTP.BAD_REQUEST + except Exception as e: + Logger.error(f"Error starting server: {e}") + Logger.debug(f"Error details: {traceback.format_exc()}") + return {"message": "Internal Server Error"}, HTTP.INTERNAL_SERVER_ERROR + + @self.__app.route('/api/stop_server/', methods=['POST']) #pyright: ignore[reportArgumentType, reportUntypedFunctionDecorator] + @self.request_auth(AccessLevel.ADMIN) + def stop_server(server_name: str) -> FlaskReturnData: + Logger.trace(f"API request for path: {request.path}") + try: + self.stop_server(server_name) + return {"message": "Server stopped"}, HTTP.OK + except ValueError as ve: + Logger.debug(f"Stop server error: {ve}") + return {"message": "Bad Request"}, HTTP.BAD_REQUEST + except Exception as e: + Logger.error(f"Error stopping server: {e}") + Logger.debug(f"Error details: {traceback.format_exc()}") + return {"message": "Internal Server Error"}, HTTP.INTERNAL_SERVER_ERROR + + @self.__app.route('/api/restart_server/', methods=['POST']) #pyright: ignore[reportArgumentType, reportUntypedFunctionDecorator] + @self.request_auth(AccessLevel.ADMIN) + def restart_server(server_name: str) -> FlaskReturnData: + Logger.trace(f"API request for path: {request.path}") + try: + self.restart_server(server_name) + return {"message": "Server restarted"}, HTTP.OK + except ValueError as ve: + Logger.debug(f"Restart server error: {ve}") + return {"message": "Bad Request"}, HTTP.BAD_REQUEST + except Exception as e: + Logger.error(f"Error restarting server: {e}") + Logger.debug(f"Error details: {traceback.format_exc()}") + return {"message": "Internal Server Error"}, HTTP.INTERNAL_SERVER_ERROR + +################################################################################################### +# endregion: server +# USER RELATED ENDPOINTS +# region: user +################################################################################################### + def __config_api_route_user(self): + + @self.__app.route('/api/login', methods=['POST']) #pyright: ignore[reportArgumentType] + def login() -> FlaskReturnData: + Logger.trace(f"API request for path: {request.path}") + try: + data = request.get_json() + username = data.get('username', None) + password = data.get('password', None) + remember = str2bool(data.get('remember', 'false')) + token = self.login(username, password, remember) + return {"token": token.token}, HTTP.OK + except ValueError as ve: + Logger.debug(f"Login error: {ve}") + return {"message": "Bad Request"}, HTTP.BAD_REQUEST + except Exception as e: + Logger.error(f"Error processing login request: {e}") + Logger.debug(f"Error details: {traceback.format_exc()}") + return {"message": "Internal Server Error"}, HTTP.INTERNAL_SERVER_ERROR + + @self.__app.route('/api/register', methods=['POST']) #pyright: ignore[reportArgumentType] + def register() -> FlaskReturnData: + Logger.debug(f"API request for path: {request.path}") + Logger.trace(request.get_json()) + try: + data = request.get_json() + username = data.get('username', None) + password = data.get('password', None) + remember = str2bool(data.get('remember', 'false')) + token = self.register(username, password, remember) + return { "token": token.token }, HTTP.CREATED + except ValueError as ve: + Logger.debug(f"Register error: {ve}") + return {"message": "Bad Request"}, HTTP.BAD_REQUEST + except Exception as e: + Logger.error(f"Error processing register request: {e}") + Logger.debug(f"Error details: {traceback.format_exc()}") + return {"message": "Internal Server Error"}, HTTP.INTERNAL_SERVER_ERROR + + @self.__app.route('/api/logout', methods=['POST']) #pyright: ignore[reportArgumentType] + @self.request_auth(AccessLevel.USER) + def logout(token: str) -> FlaskReturnData: + Logger.trace(f"API request for path: {request.path}") + try: + self.logout(token) + return {"message": "Logged out"}, HTTP.OK + except ValueError as ve: + Logger.debug(f"Logout error: {ve}") + return {"message": "Bad Request"}, HTTP.BAD_REQUEST + except Exception as e: + Logger.error(f"Error processing logout request: {e}") + Logger.debug(f"Error details: {traceback.format_exc()}") + return {"message" : "Internal Server Error"}, HTTP.INTERNAL_SERVER_ERROR + + @self.__app.route('/api/delete_user', methods=['POST']) + @self.request_auth(AccessLevel.USER) + def delete_user(token: str): # delete the user associated with the token + Logger.trace(f"API request for path: {request.path}") + try: + self.delete_user(token) + return {"message": "User deleted"}, HTTP.OK + except ValueError as ve: + Logger.debug(f"Delete user error: {ve}") + return {"message": "Bad Request"}, HTTP.BAD_REQUEST + except Exception as e: + Logger.error(f"Error processing delete user request: {e}") + Logger.debug(f"Error details: {traceback.format_exc()}") + return {"message": "Internal Server Error"}, HTTP.INTERNAL_SERVER_ERROR + + @self.__app.route('/api/user', methods=['GET']) + @self.request_auth(AccessLevel.USER) + def get_user_info(token : str): + Logger.trace(f"API request for path: {request.path}") + try: + user = self.get_user_info(token) + return { + "username": user.username, + "access_level": user.access_level.name, + "registered_at": user.registered_at.strftime("%d/%m/%Y, %H:%M:%S"), + "last_login": user.last_login.strftime("%d/%m/%Y, %H:%M:%S") + }, HTTP.OK + except ValueError as ve: + Logger.debug(f"Get user info error: {ve}") + return {"message": "Bad Request"}, HTTP.BAD_REQUEST + except Exception as e: + Logger.error(f"Error processing user info request: {e}") + Logger.debug(f"Error details: {traceback.format_exc()}") + return {"message": "Internal Server Error"}, HTTP.INTERNAL_SERVER_ERROR + + @self.__app.route('/api/user/update_password', methods=['POST']) + @self.request_auth(AccessLevel.USER) + def update_password(token: str): # update the password of the user associated with the token + Logger.trace(f"API request for path: {request.path}") + try: + data = request.get_json() + password = data.get('password', None) + self.update_password(token, password) + return {"message": "User updated"}, HTTP.OK + except ValueError as ve: + Logger.debug(f"Update password error: {ve}") + return {"message": "Bad Request"}, HTTP.BAD_REQUEST + except Exception as e: + Logger.error(f"Error processing user info request: {e}") + Logger.debug(f"Error details: {traceback.format_exc()}") + return {"message": "Internal Server Error"}, HTTP.INTERNAL_SERVER_ERROR + + @self.__app.route('/api/user/', methods=['GET']) + @self.request_auth(AccessLevel.OPERATOR) + def get_user_info_by_username(username: str): + Logger.trace(f"API request for path: {request.path}") + try: + user = self.get_user_info_by_username(username) + return { + "username": user.username, + "access_level": user.access_level.name, + "registered_at": user.registered_at.strftime("%d/%m/%Y, %H:%M:%S"), + "last_login": user.last_login.strftime("%d/%m/%Y, %H:%M:%S") + }, HTTP.OK + except ValueError as ve: + Logger.debug(f"Get user info by username error: {ve}") + return {"message": "Bad Request"}, HTTP.BAD_REQUEST + except Exception as e: + Logger.error(f"Error processing user info request: {e}") + Logger.debug(f"Error details: {traceback.format_exc()}") + return {"message": "Internal Server Error"}, HTTP.INTERNAL_SERVER_ERROR + + @self.__app.route('/api/user//global_access', methods=['POST']) + @self.request_auth(AccessLevel.OPERATOR) + def update_user_global_access(username: str): # update the global access level of the user + Logger.trace(f"API request for path: {request.path}") + try: + data = request.get_json() + access_level = data.get('access_level', None) + self.update_user_access(username, access_level) + return {"message": "User updated"}, HTTP.OK + except ValueError as ve: + Logger.debug(f"Update user global access error: {ve}") + return {"message": "Bad Request"}, HTTP.BAD_REQUEST + except Exception as e: + Logger.error(f"Error processing user info request: {e}") + Logger.debug(f"Error details: {traceback.format_exc()}") + return {"message": "Internal Server Error"}, HTTP.INTERNAL_SERVER_ERROR + + @self.__app.route('/api/user//password', methods=['POST']) + @self.request_auth(AccessLevel.OPERATOR) + def update_user_password(username: str): + Logger.trace(f"API request for path: {request.path}") + try: + data = request.get_json() + password = data.get('password', None) + self.update_user_password(username, password) + return {"message": "User updated"}, HTTP.OK + except ValueError as ve: + Logger.debug(f"Update user password error: {ve}") + return {"message": "Bad Request"}, HTTP.BAD_REQUEST + except Exception as e: + Logger.error(f"Error processing user info request: {e}") + Logger.debug(f"Error details: {traceback.format_exc()}") + return {"message": "Internal Server Error"}, HTTP.INTERNAL_SERVER_ERROR + +################################################################################################### +# endregion: user +################################################################################################### diff --git a/web_server/utils.py b/web_server/utils.py new file mode 100644 index 0000000..553ca03 --- /dev/null +++ b/web_server/utils.py @@ -0,0 +1,48 @@ +import re + +from gamuLogger import Logger + +Logger.set_module("User Interface.Utils") + +RE_MC_SERVER_NAME = re.compile(r"^[a-zA-Z0-9_]{1,16}$") # Matches Minecraft server names (1-16 characters, letters, numbers, underscores) + +def str2bool(v : str) -> bool: + """ + Convert a string to a boolean value. + """ + if isinstance(v, bool): + return v + if v.lower() in {'yes', 'true', 't', '1'}: + return True + if v.lower() in {'no', 'false', 'f', '0'}: + return False + raise ValueError(f"Invalid boolean string: {v}") + +class NoLog: + def write(self, *_): pass + def flush(self): pass + +def guess_type(filename: str) -> str: + """ + Guess the MIME type of a file based on its extension. + """ + mimetypes = { + 'html': 'text/html', + 'css': 'text/css', + 'js': 'application/javascript', + 'json': 'application/json', + 'png': 'image/png', + 'jpeg': 'image/jpeg', + 'gif': 'image/gif', + 'svg': 'image/svg+xml', + 'webp': 'image/webp', + 'woff': 'font/woff', + 'woff2': 'font/woff2', + 'ttf': 'font/ttf', + 'otf': 'font/otf' + } + ext = filename.split('.')[-1].lower() + if ext not in mimetypes: + Logger.warning(f"Unknown file extension: {ext}, defaulting to application/octet-stream") + return 'application/octet-stream' + return mimetypes[ext] \ No newline at end of file diff --git a/web_server/web_server.py b/web_server/web_server.py new file mode 100644 index 0000000..82ef1fa --- /dev/null +++ b/web_server/web_server.py @@ -0,0 +1,159 @@ +import sys +from datetime import datetime + +import eventlet +import socketio +from eventlet import wsgi +from gamuLogger import Logger +from version import Version +from typing import Any + +from .utils import NoLog +from .http_server import HttpServer +from .websocket_server import WebSocketServer + +Logger.set_module("User Interface.Web Server") + +class WebServer(HttpServer, WebSocketServer): + def __init__(self, *args: Any, **kwargs: Any): + Logger.trace("Initializing WebServer") + HttpServer.__init__(self, + *args, + **kwargs + ) + WebSocketServer.__init__(self, + *args, + **kwargs + ) + + def start(self): + super().start() + Logger.info(f"Starting HTTP server on port {self._port}") + try: + app = socketio.WSGIApp(self._get_sio(), self._get_app()) + wsgi.server(eventlet.listen(('', self._port), reuse_addr=True), app, log=NoLog()) + except KeyboardInterrupt: + Logger.info("HTTP server stopped by user") + except Exception as e: + Logger.fatal(f"WebServer encountered an error: {e}") + sys.exit(1) + finally: + sys.stdout.write("\r") + sys.stdout.flush() + Logger.info("Stopping HTTP server...") + Logger.info("HTTP server stopped") + + def stop(self): + Logger.info("Stopping WebServer...") + HttpServer.stop(self) + WebSocketServer.stop(self) + Logger.info("WebServer stopped") + + +####################################### EVENT TRANSMISSION ######################################## + + def on_server_starting(self, timestamp: datetime, server_name: str) -> None: + self.send("server_starting", { + "timestamp": timestamp.isoformat(), + "server_name": server_name + }) + + def on_server_started(self, timestamp: datetime, server_name: str) -> None: + self.send("server_started", { + "timestamp": timestamp.isoformat(), + "server_name": server_name + }) + + def on_server_stopping(self, timestamp: datetime, server_name: str) -> None: + self.send("server_stopping", { + "timestamp": timestamp.isoformat(), + "server_name": server_name + }) + + def on_server_stopped(self, timestamp: datetime, server_name: str) -> None: + self.send("server_stopped", { + "timestamp": timestamp.isoformat(), + "server_name": server_name + }) + + def on_server_crashed(self, timestamp: datetime, server_name: str) -> None: + self.send("server_crashed", { + "timestamp": timestamp.isoformat(), + "server_name": server_name + }) + + def on_server_created(self, timestamp: datetime, server_name: str, server_type: str, server_path: str, autostart: bool, mc_version: Version, modloader_version: Version, ram: int) -> None: + self.send("server_created", { + "timestamp": timestamp.isoformat(), + "server_name": server_name, + "server_type": server_type, + "server_path": server_path, + "autostart": autostart, + "mc_version": mc_version, + "modloader_version": modloader_version, + "ram": ram + }) + + def on_server_deleted(self, timestamp: datetime, server_name: str) -> None: + self.send("server_deleted", { + "timestamp": timestamp.isoformat(), + "server_name": server_name + }) + + def on_server_renamed(self, timestamp: datetime, old_name: str, new_name: str) -> None: + self.send("server_renamed", { + "timestamp": timestamp.isoformat(), + "old_name": old_name, + "new_name": new_name + }) + + def on_console_message_received(self, timestamp: datetime, server_name: str, message: str) -> None: + self.send("console_message_received", { + "timestamp": timestamp.isoformat(), + "server_name": server_name, + "message": message + }) + + def on_console_log_received(self, timestamp: datetime, server_name: str, log: str) -> None: + self.send("console_log_received", { + "timestamp": timestamp.isoformat(), + "server_name": server_name, + "log": log + }) + + def on_player_joined(self, timestamp: datetime, server_name: str, player_name: str) -> None: + self.send("player_joined", { + "timestamp": timestamp.isoformat(), + "server_name": server_name, + "player_name": player_name + }) + + def on_player_left(self, timestamp: datetime, server_name: str, player_name: str) -> None: + self.send("player_left", { + "timestamp": timestamp.isoformat(), + "server_name": server_name, + "player_name": player_name + }) + + def on_player_kicked(self, timestamp: datetime, server_name: str, player_name: str, reason: str) -> None: + self.send("player_kicked", { + "timestamp": timestamp.isoformat(), + "server_name": server_name, + "player_name": player_name, + "reason": reason + }) + + def on_player_banned(self, timestamp: datetime, server_name: str, player_name: str, reason: str) -> None: + self.send("player_banned", { + "timestamp": timestamp.isoformat(), + "server_name": server_name, + "player_name": player_name, + "reason": reason + }) + + def on_player_pardoned(self, timestamp: datetime, server_name: str, player_name: str) -> None: + self.send("player_pardoned", { + "timestamp": timestamp.isoformat(), + "server_name": server_name, + "player_name": player_name + }) diff --git a/web_server/websocket_server.py b/web_server/websocket_server.py new file mode 100644 index 0000000..f4f09e8 --- /dev/null +++ b/web_server/websocket_server.py @@ -0,0 +1,45 @@ +import socketio +from gamuLogger import Logger +from typing import Any + +from ..Base_interface import BaseInterface # will be changed to be an external dependency + +Logger.set_module("User Interface.WebSock Server") + +class WebSocketServer(BaseInterface): + def __init__(self, *args: Any, **kwargs: Any): + Logger.trace("Initializing WebSocketServer") + BaseInterface.__init__(self, + *args, + **kwargs + ) + self.__sio = socketio.Server(cors_allowed_origins='*') + self.__config_routes() + + def __config_routes(self): + @self.__sio.event + def connect(sid, environ): + Logger.info(f"Client connected: {sid}") + + @self.__sio.event + def disconnect(sid): + Logger.info(f"Client disconnected: {sid}") + + def send(self, event: str, data: dict[str, Any]): + """ + Send a message to all connected clients. + + :param event: The event name to send. + :param data: The data to send with the event. + """ + self.__sio.emit(event, data) + Logger.debug(f"Sent event '{event}' with data: {data}") + + + def _get_sio(self): + """ + Get the SocketIO server instance. + + :return: The SocketIO server instance. + """ + return self.__sio From 27da4508d3cd439a629f0764c2aadcd214acf85b Mon Sep 17 00:00:00 2001 From: Antoine <91411073+T0ine34@users.noreply.github.com> Date: Wed, 22 Oct 2025 23:21:22 +0200 Subject: [PATCH 2/8] update relative to the migration of the web component --- .gitignore | 5 +++- build_package.py | 21 ++++++++++++++++ makefile | 13 ++++++++++ .../__init__.py | 0 .../http_server.py | 3 +-- .../utils.py | 0 .../web_server.py | 4 +++ .../websocket_server.py | 2 +- pyproject.toml | 10 +++++--- src/modular_server_manager.egg-info/PKG-INFO | 25 ------------------- .../SOURCES.txt | 8 ------ .../dependency_links.txt | 1 - .../requires.txt | 17 ------------- .../top_level.txt | 3 --- 14 files changed, 51 insertions(+), 61 deletions(-) create mode 100644 build_package.py create mode 100644 makefile rename {web_server => modular_server_manager_web_client}/__init__.py (100%) rename {web_server => modular_server_manager_web_client}/http_server.py (99%) rename {web_server => modular_server_manager_web_client}/utils.py (100%) rename {web_server => modular_server_manager_web_client}/web_server.py (98%) rename {web_server => modular_server_manager_web_client}/websocket_server.py (92%) delete mode 100644 src/modular_server_manager.egg-info/PKG-INFO delete mode 100644 src/modular_server_manager.egg-info/SOURCES.txt delete mode 100644 src/modular_server_manager.egg-info/dependency_links.txt delete mode 100644 src/modular_server_manager.egg-info/requires.txt delete mode 100644 src/modular_server_manager.egg-info/top_level.txt diff --git a/.gitignore b/.gitignore index 62ea235..b386686 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,7 @@ src/assets/ # Editor / OS .vscode/ .idea/ -.DS_Store \ No newline at end of file +.DS_Store + +*.log +*.egg-info/ \ No newline at end of file diff --git a/build_package.py b/build_package.py new file mode 100644 index 0000000..9637c57 --- /dev/null +++ b/build_package.py @@ -0,0 +1,21 @@ +""" +This script builds a Python package using the `build` module. +It allows for passing additional arguments to the build command and +supports specifying a version number via command line arguments. +It also sets the `PACKAGE_VERSION` environment variable if a version is provided. +""" + +import os +import subprocess +import sys + +if "--version" in sys.argv: + idx = sys.argv.index("--version") + version = sys.argv[idx + 1] + os.environ["PACKAGE_VERSION"] = version + sys.argv.pop(idx) # remove --version + sys.argv.pop(idx) # remove version value + +cmd = [sys.executable, "-m", "build"] + sys.argv[1:] +print(" ".join(cmd)) # Print the command for debugging +subprocess.run(cmd, check=True, stderr=sys.stderr, stdout=sys.stdout) diff --git a/makefile b/makefile new file mode 100644 index 0000000..c313034 --- /dev/null +++ b/makefile @@ -0,0 +1,13 @@ + + +build: + python ./build_package.py --outdir dist --version "0.1.0" + +install: + pip install --upgrade --force-reinstall ./dist/modular_server_manager_web_client-0.1.0-py3-none-any.whl + +start: + ./env/bin/modular-server-manager \ + -c /var/minecraft/config.json \ + --log-file server.trace.log:TRACE \ + --log-file server.debug.log:DEBUG \ No newline at end of file diff --git a/web_server/__init__.py b/modular_server_manager_web_client/__init__.py similarity index 100% rename from web_server/__init__.py rename to modular_server_manager_web_client/__init__.py diff --git a/web_server/http_server.py b/modular_server_manager_web_client/http_server.py similarity index 99% rename from web_server/http_server.py rename to modular_server_manager_web_client/http_server.py index 5daa744..22aad5e 100644 --- a/web_server/http_server.py +++ b/modular_server_manager_web_client/http_server.py @@ -13,8 +13,7 @@ from version import Version from .utils import str2bool, guess_type, RE_MC_SERVER_NAME -from ..Base_interface import BaseInterface # will be changed to be an external dependency -from ..database.types import AccessLevel # will be changed to be an external dependency +from modular_server_manager import BaseInterface, AccessLevel Logger.set_module("User Interface.Http Server") diff --git a/web_server/utils.py b/modular_server_manager_web_client/utils.py similarity index 100% rename from web_server/utils.py rename to modular_server_manager_web_client/utils.py diff --git a/web_server/web_server.py b/modular_server_manager_web_client/web_server.py similarity index 98% rename from web_server/web_server.py rename to modular_server_manager_web_client/web_server.py index 82ef1fa..3648b2a 100644 --- a/web_server/web_server.py +++ b/modular_server_manager_web_client/web_server.py @@ -11,6 +11,7 @@ from .utils import NoLog from .http_server import HttpServer from .websocket_server import WebSocketServer +from modular_server_manager import UserInterfaceModules Logger.set_module("User Interface.Web Server") @@ -157,3 +158,6 @@ def on_player_pardoned(self, timestamp: datetime, server_name: str, player_name: "server_name": server_name, "player_name": player_name }) + + +UserInterfaceModules['web'] = WebServer \ No newline at end of file diff --git a/web_server/websocket_server.py b/modular_server_manager_web_client/websocket_server.py similarity index 92% rename from web_server/websocket_server.py rename to modular_server_manager_web_client/websocket_server.py index f4f09e8..df1a290 100644 --- a/web_server/websocket_server.py +++ b/modular_server_manager_web_client/websocket_server.py @@ -2,7 +2,7 @@ from gamuLogger import Logger from typing import Any -from ..Base_interface import BaseInterface # will be changed to be an external dependency +from modular_server_manager import BaseInterface Logger.set_module("User Interface.WebSock Server") diff --git a/pyproject.toml b/pyproject.toml index af79110..90bf6f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] -name = "modular-server-manager" -version = "0.1.3" -description = "Modular Server Manager" +name = "modular-server-manager-web-client" +version = "0.1.0" +description = "MSM Web Client" authors = [ { name = "Antoine BUIREY", email = "antoine.buirey@gmail.com" } ] @@ -24,4 +24,8 @@ dependencies = [ "http_code @ https://github.com/T0ine34/python-sample/releases/download/1.1.6/http_code-1.1.6-py3-none-any.whl", "singleton @ https://github.com/T0ine34/python-sample/releases/download/1.1.6/singleton-1.1.6-py3-none-any.whl", "version @ https://github.com/T0ine34/python-sample/releases/download/1.1.6/version-1.1.6-py3-none-any.whl", + "modular-server-manager @ https://github.com/modular-server-manager/server/releases/download/0.1.4/modular_server_manager-0.1.4-py3-none-any.whl" ] + +[tool.setuptools] +packages = ["modular_server_manager_web_client"] diff --git a/src/modular_server_manager.egg-info/PKG-INFO b/src/modular_server_manager.egg-info/PKG-INFO deleted file mode 100644 index a00d550..0000000 --- a/src/modular_server_manager.egg-info/PKG-INFO +++ /dev/null @@ -1,25 +0,0 @@ -Metadata-Version: 2.4 -Name: modular-server-manager -Version: 0.1.3 -Summary: Modular Server Manager -Author-email: Antoine BUIREY -Requires-Python: >=3.12 -License-File: LICENSE -Requires-Dist: blinker==1.9.0 -Requires-Dist: click==8.1.8 -Requires-Dist: Flask==3.1.1 -Requires-Dist: gamuLogger>=3.2.4 -Requires-Dist: itsdangerous==2.2.0 -Requires-Dist: Jinja2==3.1.6 -Requires-Dist: MarkupSafe==3.0.2 -Requires-Dist: typing_extensions==4.13.2 -Requires-Dist: urllib3==2.5.0 -Requires-Dist: Werkzeug==3.1.3 -Requires-Dist: dnspython==2.7.0 -Requires-Dist: eventlet==0.40.3 -Requires-Dist: greenlet==3.2.1 -Requires-Dist: python-socketio==5.14.0 -Requires-Dist: http_code@ https://github.com/T0ine34/python-sample/releases/download/1.1.6/http_code-1.1.6-py3-none-any.whl -Requires-Dist: singleton@ https://github.com/T0ine34/python-sample/releases/download/1.1.6/singleton-1.1.6-py3-none-any.whl -Requires-Dist: version@ https://github.com/T0ine34/python-sample/releases/download/1.1.6/version-1.1.6-py3-none-any.whl -Dynamic: license-file diff --git a/src/modular_server_manager.egg-info/SOURCES.txt b/src/modular_server_manager.egg-info/SOURCES.txt deleted file mode 100644 index 0e42343..0000000 --- a/src/modular_server_manager.egg-info/SOURCES.txt +++ /dev/null @@ -1,8 +0,0 @@ -LICENSE -README.md -pyproject.toml -src/modular_server_manager.egg-info/PKG-INFO -src/modular_server_manager.egg-info/SOURCES.txt -src/modular_server_manager.egg-info/dependency_links.txt -src/modular_server_manager.egg-info/requires.txt -src/modular_server_manager.egg-info/top_level.txt \ No newline at end of file diff --git a/src/modular_server_manager.egg-info/dependency_links.txt b/src/modular_server_manager.egg-info/dependency_links.txt deleted file mode 100644 index 8b13789..0000000 --- a/src/modular_server_manager.egg-info/dependency_links.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/modular_server_manager.egg-info/requires.txt b/src/modular_server_manager.egg-info/requires.txt deleted file mode 100644 index 8077d52..0000000 --- a/src/modular_server_manager.egg-info/requires.txt +++ /dev/null @@ -1,17 +0,0 @@ -blinker==1.9.0 -click==8.1.8 -Flask==3.1.1 -gamuLogger>=3.2.4 -itsdangerous==2.2.0 -Jinja2==3.1.6 -MarkupSafe==3.0.2 -typing_extensions==4.13.2 -urllib3==2.5.0 -Werkzeug==3.1.3 -dnspython==2.7.0 -eventlet==0.40.3 -greenlet==3.2.1 -python-socketio==5.14.0 -http_code@ https://github.com/T0ine34/python-sample/releases/download/1.1.6/http_code-1.1.6-py3-none-any.whl -singleton@ https://github.com/T0ine34/python-sample/releases/download/1.1.6/singleton-1.1.6-py3-none-any.whl -version@ https://github.com/T0ine34/python-sample/releases/download/1.1.6/version-1.1.6-py3-none-any.whl diff --git a/src/modular_server_manager.egg-info/top_level.txt b/src/modular_server_manager.egg-info/top_level.txt deleted file mode 100644 index 4236120..0000000 --- a/src/modular_server_manager.egg-info/top_level.txt +++ /dev/null @@ -1,3 +0,0 @@ -assets -scripts -styles From e4bcedfa5a7ab90719ea26808d788bfca7ffcde8 Mon Sep 17 00:00:00 2001 From: Antoine <91411073+T0ine34@users.noreply.github.com> Date: Sat, 1 Nov 2025 10:54:06 +0100 Subject: [PATCH 3/8] upgrading structure --- .gitignore | 6 +- build_package.py | 18 ++++ get_version.py | 41 ++++++++ makefile | 97 +++++++++++++++++-- package.json | 10 +- pyproject.toml | 7 ++ .../server}/__init__.py | 0 src/server/compatibility.json | 10 ++ .../server}/http_server.py | 3 +- .../server}/utils.py | 0 .../server}/web_server.py | 0 .../server}/websocket_server.py | 0 src/{ => web}/index.html | 0 src/{ => web}/main.ts | 0 src/{ => web}/scripts/api.ts | 0 src/{ => web}/scripts/app.ts | 0 src/{ => web}/scripts/cookie.ts | 0 src/{ => web}/scripts/types.ts | 0 src/{ => web}/styles/main.scss | 0 tsconfig.json | 2 +- 20 files changed, 176 insertions(+), 18 deletions(-) create mode 100644 get_version.py rename {modular_server_manager_web_client => src/server}/__init__.py (100%) create mode 100644 src/server/compatibility.json rename {modular_server_manager_web_client => src/server}/http_server.py (99%) rename {modular_server_manager_web_client => src/server}/utils.py (100%) rename {modular_server_manager_web_client => src/server}/web_server.py (100%) rename {modular_server_manager_web_client => src/server}/websocket_server.py (100%) rename src/{ => web}/index.html (100%) rename src/{ => web}/main.ts (100%) rename src/{ => web}/scripts/api.ts (100%) rename src/{ => web}/scripts/app.ts (100%) rename src/{ => web}/scripts/cookie.ts (100%) rename src/{ => web}/scripts/types.ts (100%) rename src/{ => web}/styles/main.scss (100%) diff --git a/.gitignore b/.gitignore index b386686..d98d7cf 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,7 @@ __pycache__/ # Build output dist/ -src/assets/ +src/web/assets/ # Editor / OS .vscode/ @@ -15,4 +15,6 @@ src/assets/ .DS_Store *.log -*.egg-info/ \ No newline at end of file +*.egg-info/ + +modular_server_manager_web_client/ \ No newline at end of file diff --git a/build_package.py b/build_package.py index 9637c57..e397155 100644 --- a/build_package.py +++ b/build_package.py @@ -8,11 +8,29 @@ import os import subprocess import sys +import re + + +def patch_pyproject_version(version: str): + pyproject_path = "pyproject.toml" + with open(pyproject_path, "r") as f: + content = f.read() + + new_content = re.sub( + r'version\s*=\s*"[0-9a-zA-Z\.\-\_]+"', + f'version = "{version}"', + content + ) + + with open(pyproject_path, "w") as f: + f.write(new_content) + if "--version" in sys.argv: idx = sys.argv.index("--version") version = sys.argv[idx + 1] os.environ["PACKAGE_VERSION"] = version + patch_pyproject_version(version) sys.argv.pop(idx) # remove --version sys.argv.pop(idx) # remove version value diff --git a/get_version.py b/get_version.py new file mode 100644 index 0000000..11e1915 --- /dev/null +++ b/get_version.py @@ -0,0 +1,41 @@ + +def install_if_not_installed(package_name, url): + try: + __import__(package_name) + except ImportError: + import subprocess + import sys + subprocess.check_call([sys.executable, "-m", "pip", "install", url]) + + + +install_if_not_installed("version", "https://github.com/T0ine34/python-sample/releases/download/1.0.2/version-1.0.2-py3-none-any.whl") + + +import os + +from version import Version + +branch = os.popen("git rev-parse --abbrev-ref HEAD").read().strip() + +def get_tag(): + tags_list = os.popen("git tag").read().strip().split("\n") + tags = [Version.from_string(t) for t in tags_list if t] + tags.sort(reverse=True) + return tags[0] if tags else Version(0, 0, 0) + +try: + version = Version.from_string(branch) +except ValueError: + version = get_tag() + version.prerelease = "alpha" + + if version.minor >= 1: + version.patch_increment() + else: + version.minor_increment() + +last_commit = os.popen("git rev-parse HEAD").read().strip() + +version.metadata = last_commit +print(version) diff --git a/makefile b/makefile index c313034..eeb18b7 100644 --- a/makefile +++ b/makefile @@ -1,13 +1,92 @@ +.PHONY: all build clean +all: build -build: - python ./build_package.py --outdir dist --version "0.1.0" +TEMP_DIR = build -install: - pip install --upgrade --force-reinstall ./dist/modular_server_manager_web_client-0.1.0-py3-none-any.whl +PYPROJECT = pyproject.toml -start: - ./env/bin/modular-server-manager \ - -c /var/minecraft/config.json \ - --log-file server.trace.log:TRACE \ - --log-file server.debug.log:DEBUG \ No newline at end of file + +BUILD_DIR = modular_server_manager_web_client/ +WEB_BUILD_DIR = $(BUILD_DIR)client + +PYTHON_PATH = $(shell if [ -d env/bin ]; then echo "env/bin/"; elif [ -d env/Scripts ]; then echo "env/Scripts/"; else echo ""; fi) +PYTHON_LIB = $(shell find env/lib -type d -name "site-packages" | head -n 1; if [ -d env/Lib/site-packages ]; then echo "env/Lib/site-packages/"; fi) +PYTHON = $(PYTHON_PATH)python + +EXECUTABLE_EXTENSION = $(shell if [ -d env/bin ]; then echo ""; elif [ -d env/Scripts ]; then echo ".exe"; else echo ""; fi) +APP_EXECUTABLE = $(PYTHON_PATH)modular-server-manager$(EXECUTABLE_EXTENSION) + +INSTAL_PATH = $(PYTHON_LIB)/modular_server_manager_web_client + +# if not defined, get the version from git +VERSION ?= $(shell $(PYTHON) get_version.py) + +# if version is in the form of x.y.z-dev-aaaa or x.y.z-dev+aaaa, set it to x.y.z-dev +VERSION_STR = $(shell echo $(VERSION) | sed "s/-dev-[a-z0-9]*//; s/-dev+.*//") + +WHEEL = modular_server_manager_web_client-$(VERSION_STR)-py3-none-any.whl +ARCHIVE = modular_server_manager_web_client-$(VERSION_STR).tar.gz + +SRV_SRC_DIR = src/server/ +SRV_SRC = $(shell find $(SRV_SRC_DIR) -type f -name "*.py") $(SRV_SRC_DIR)compatibility.json +SRV_DIST = $(patsubst $(SRV_SRC_DIR)%,$(BUILD_DIR)%,$(SRV_SRC)) + +WEB_SRC_DIR = src/web/ +WEB_SRC = $(shell find $(WEB_SRC_DIR) -type f -name "*.html" -o -name "*.scss" -o -name "*.ts") +WEB_DIST = $(WEB_BUILD_DIR)/index.html $(WEB_BUILD_DIR)/assets/css/main.css $(WEB_BUILD_DIR)/assets/app.js + + +print-%: + @echo $* = $($*) + +dist: + mkdir -p dist + +dist/$(WHEEL): $(SRV_DIST) $(PYPROJECT) $(WEB_DIST) $(PYTHON_LIB)/build dist + mkdir -p $(TEMP_DIR) + $(PYTHON) build_package.py --outdir $(TEMP_DIR) --wheel --version $(VERSION_STR) + mkdir -p dist + mv $(TEMP_DIR)/*.whl dist/$(WHEEL) + rm -rf $(TEMP_DIR) + @echo "Building wheel package complete." + +dist/$(ARCHIVE): $(SRV_DIST) $(PYPROJECT) $(WEB_DIST) $(PYTHON_LIB)/build dist + mkdir -p $(TEMP_DIR) + $(PYTHON) build_package.py --outdir $(TEMP_DIR) --sdist --version $(VERSION_STR) + mkdir -p dist + mv $(TEMP_DIR)/*.tar.gz dist/$(ARCHIVE) + rm -rf $(TEMP_DIR) + @echo "Building archive package complete." + +$(WEB_DIST): $(WEB_SRC) + npm run build + +$(BUILD_DIR)%: $(SRV_SRC_DIR)% + @mkdir -p $(@D) + @echo "Copying $< to $@" + @cp $< $@ + + +$(INSTAL_PATH) : dist/$(WHEEL) + @echo "Installing package..." + @$(PYTHON) -m pip install --upgrade --force-reinstall dist/$(WHEEL) + @echo "Package installed." + + +build: dist/$(WHEEL) dist/$(ARCHIVE) + +install: $(INSTAL_PATH) + +start: install + @$(APP_EXECUTABLE) \ + -c /var/minecraft/config.json \ + --log-file server.trace.log:TRACE \ + --log-file server.debug.log:DEBUG + + +clean: + rm -rf $(BUILD_DIR) + rm -rf dist + rm -rf $(PYTHON_LIB)/modular_server_manager_web_client + rm -rf $(PYTHON_LIB)/modular_server_manager_web_client-*.dist-info diff --git a/package.json b/package.json index 1ae6991..a1bae1d 100644 --- a/package.json +++ b/package.json @@ -2,12 +2,12 @@ "name": "web-client", "version": "0.1.0", "private": true, - "description": "Web client (TypeScript + SCSS + SCSS + HTML) — lightweight build using esbuild and sass", + "description": "Web modular_server_manager_web_client/client (TypeScript + SCSS + SCSS + HTML) — lightweight build using esbuild and sass", "scripts": { - "dev": "concurrently \"esbuild src/main.ts --bundle --outfile=src/assets/app.js --sourcemap --watch\" \"sass src/styles/main.scss src/assets/css/main.css --watch\" \"live-server src --port=3000 --open=./index.html\"", - "build": "rimraf dist && mkdir -p dist && sass src/styles/main.scss dist/assets/css/main.css --no-source-map && esbuild src/main.ts --bundle --minify --target=es2017 --outfile=dist/assets/app.js && cpy \"src/*.html\" dist/", - "clean": "rimraf dist src/assets", - "format": "prettier --write \"src/**/*.{ts,scss,html}\"" + "dev": "concurrently \"esbuild src/web/main.ts --bundle --outfile=src/web/assets/app.js --sourcemap --watch\" \"sass src/web/styles/main.scss src/web/assets/css/main.css --watch\" \"live-server src/web --port=3000 --open=./index.html\"", + "build": "rimraf modular_server_manager_web_client/client && mkdir -p modular_server_manager_web_client/client && sass src/web/styles/main.scss modular_server_manager_web_client/client/assets/css/main.css --no-source-map && esbuild src/web/main.ts --bundle --minify --target=es2017 --outfile=modular_server_manager_web_client/client/assets/app.js && cpy \"src/web/*.html\" modular_server_manager_web_client/client/", + "clean": "rimraf modular_server_manager_web_client/client src/web/assets", + "format": "prettier --write \"src/web**/*.{ts,scss,html}\"" }, "devDependencies": { "esbuild": "^0.19.0", diff --git a/pyproject.toml b/pyproject.toml index 90bf6f8..045d731 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,3 +29,10 @@ dependencies = [ [tool.setuptools] packages = ["modular_server_manager_web_client"] + +package-data = { "modular_server_manager_web_client" = [ + "client/*", + "client/**/*", + "compatibility.json", +] } +include-package-data = true \ No newline at end of file diff --git a/modular_server_manager_web_client/__init__.py b/src/server/__init__.py similarity index 100% rename from modular_server_manager_web_client/__init__.py rename to src/server/__init__.py diff --git a/src/server/compatibility.json b/src/server/compatibility.json new file mode 100644 index 0000000..c442db8 --- /dev/null +++ b/src/server/compatibility.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://raw.githubusercontent.com/AntoineBuirey/xml-json-schema/refs/tags/1.2.1/modular-server-manager/compatibility.schema", + "compatibility": { + "0.1.4": { + "min_module_version": "0.1.0", + "max_module_version": "0.1.0", + "recommended_module_version": "0.1.0" + } + } +} \ No newline at end of file diff --git a/modular_server_manager_web_client/http_server.py b/src/server/http_server.py similarity index 99% rename from modular_server_manager_web_client/http_server.py rename to src/server/http_server.py index 22aad5e..455f056 100644 --- a/modular_server_manager_web_client/http_server.py +++ b/src/server/http_server.py @@ -17,7 +17,7 @@ Logger.set_module("User Interface.Http Server") -STATIC_PATH = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + "/client" +STATIC_PATH = os.path.abspath(__file__) + "/client" T = TypeVar('T') @@ -115,6 +115,7 @@ def wrapper(*args: Any, **kwargs: Any) -> FlaskReturnData: def __config_static_route(self): self.__app.static_folder = STATIC_PATH + Logger.debug(f"Configuring static route with STATIC_PATH: {STATIC_PATH}") @self.__app.route('/') def root(): diff --git a/modular_server_manager_web_client/utils.py b/src/server/utils.py similarity index 100% rename from modular_server_manager_web_client/utils.py rename to src/server/utils.py diff --git a/modular_server_manager_web_client/web_server.py b/src/server/web_server.py similarity index 100% rename from modular_server_manager_web_client/web_server.py rename to src/server/web_server.py diff --git a/modular_server_manager_web_client/websocket_server.py b/src/server/websocket_server.py similarity index 100% rename from modular_server_manager_web_client/websocket_server.py rename to src/server/websocket_server.py diff --git a/src/index.html b/src/web/index.html similarity index 100% rename from src/index.html rename to src/web/index.html diff --git a/src/main.ts b/src/web/main.ts similarity index 100% rename from src/main.ts rename to src/web/main.ts diff --git a/src/scripts/api.ts b/src/web/scripts/api.ts similarity index 100% rename from src/scripts/api.ts rename to src/web/scripts/api.ts diff --git a/src/scripts/app.ts b/src/web/scripts/app.ts similarity index 100% rename from src/scripts/app.ts rename to src/web/scripts/app.ts diff --git a/src/scripts/cookie.ts b/src/web/scripts/cookie.ts similarity index 100% rename from src/scripts/cookie.ts rename to src/web/scripts/cookie.ts diff --git a/src/scripts/types.ts b/src/web/scripts/types.ts similarity index 100% rename from src/scripts/types.ts rename to src/web/scripts/types.ts diff --git a/src/styles/main.scss b/src/web/styles/main.scss similarity index 100% rename from src/styles/main.scss rename to src/web/styles/main.scss diff --git a/tsconfig.json b/tsconfig.json index 3e6485c..22dc358 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,6 @@ "resolveJsonModule": true, "lib": ["ES2019", "DOM"] }, - "include": ["src/**/*"], + "include": ["src/web/**/*"], "exclude": ["node_modules", "dist"] } \ No newline at end of file From 0d3467353e28e476535b04a6f0a04b6729378a15 Mon Sep 17 00:00:00 2001 From: Antoine <91411073+T0ine34@users.noreply.github.com> Date: Sat, 1 Nov 2025 14:54:28 +0100 Subject: [PATCH 4/8] updating compatibility and renamed interface export name --- src/server/__init__.py | 2 +- src/server/compatibility.json | 13 ++++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/server/__init__.py b/src/server/__init__.py index 9e017d6..43eec77 100644 --- a/src/server/__init__.py +++ b/src/server/__init__.py @@ -1 +1 @@ -from .web_server import WebServer +from .web_server import WebServer as Interface diff --git a/src/server/compatibility.json b/src/server/compatibility.json index c442db8..e86dcb7 100644 --- a/src/server/compatibility.json +++ b/src/server/compatibility.json @@ -1,10 +1,17 @@ { - "$schema": "https://raw.githubusercontent.com/AntoineBuirey/xml-json-schema/refs/tags/1.2.1/modular-server-manager/compatibility.schema", + "$schema": "https://raw.githubusercontent.com/AntoineBuirey/xml-json-schema/refs/tags/1.2.3/modular-server-manager/compatibility.schema", "compatibility": { + "0.1.3": { + "min_module_version": "0.1.0", + "max_module_version": "0.1.0" + }, "0.1.4": { "min_module_version": "0.1.0", - "max_module_version": "0.1.0", - "recommended_module_version": "0.1.0" + "max_module_version": "0.1.0" + }, + "0.1.5": { + "min_module_version": "0.1.1", + "max_module_version": "0.1.1" } } } \ No newline at end of file From 7bc8c27d7beb51eba4d96d67dc9eae9192d1cc09 Mon Sep 17 00:00:00 2001 From: Antoine <91411073+T0ine34@users.noreply.github.com> Date: Sat, 1 Nov 2025 14:56:55 +0100 Subject: [PATCH 5/8] fix regex in build_package --- build_package.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build_package.py b/build_package.py index e397155..e7f1a30 100644 --- a/build_package.py +++ b/build_package.py @@ -17,7 +17,7 @@ def patch_pyproject_version(version: str): content = f.read() new_content = re.sub( - r'version\s*=\s*"[0-9a-zA-Z\.\-\_]+"', + r'^version\s*=\s*"[0-9a-zA-Z\.\-\_]+"', f'version = "{version}"', content ) From 9250ddb225c541d0fc6568da83e20e4ab9e80e10 Mon Sep 17 00:00:00 2001 From: Antoine Buirey <91411073+AntoineBuirey@users.noreply.github.com> Date: Sat, 1 Nov 2025 15:15:01 +0100 Subject: [PATCH 6/8] Update compatibility.json for module version range --- src/server/compatibility.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/server/compatibility.json b/src/server/compatibility.json index e86dcb7..70d396b 100644 --- a/src/server/compatibility.json +++ b/src/server/compatibility.json @@ -10,8 +10,8 @@ "max_module_version": "0.1.0" }, "0.1.5": { - "min_module_version": "0.1.1", - "max_module_version": "0.1.1" + "min_module_version": "0.1.0", + "max_module_version": "0.1.0" } } -} \ No newline at end of file +} From cf43497e629b95f96d2d8e1f275d3e87cb74b60f Mon Sep 17 00:00:00 2001 From: Antoine <91411073+T0ine34@users.noreply.github.com> Date: Mon, 19 Jan 2026 14:24:46 +0100 Subject: [PATCH 7/8] removing server-side elements (moved to web-server repo) --- .gitignore | 6 - build_package.py | 39 --- get_version.py | 41 --- pyproject.toml | 38 --- src/{web => }/index.html | 0 src/{web => }/main.ts | 0 src/{web => }/scripts/api.ts | 0 src/{web => }/scripts/app.ts | 0 src/{web => }/scripts/cookie.ts | 0 src/{web => }/scripts/types.ts | 0 src/server/__init__.py | 1 - src/server/compatibility.json | 17 - src/server/http_server.py | 556 -------------------------------- src/server/utils.py | 48 --- src/server/web_server.py | 163 ---------- src/server/websocket_server.py | 45 --- src/{web => }/styles/main.scss | 0 17 files changed, 954 deletions(-) delete mode 100644 build_package.py delete mode 100644 get_version.py delete mode 100644 pyproject.toml rename src/{web => }/index.html (100%) rename src/{web => }/main.ts (100%) rename src/{web => }/scripts/api.ts (100%) rename src/{web => }/scripts/app.ts (100%) rename src/{web => }/scripts/cookie.ts (100%) rename src/{web => }/scripts/types.ts (100%) delete mode 100644 src/server/__init__.py delete mode 100644 src/server/compatibility.json delete mode 100644 src/server/http_server.py delete mode 100644 src/server/utils.py delete mode 100644 src/server/web_server.py delete mode 100644 src/server/websocket_server.py rename src/{web => }/styles/main.scss (100%) diff --git a/.gitignore b/.gitignore index d98d7cf..908441e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,6 @@ # Node deps node_modules/ -# Python deps -env/ -__pycache__/ - # Build output dist/ src/web/assets/ @@ -16,5 +12,3 @@ src/web/assets/ *.log *.egg-info/ - -modular_server_manager_web_client/ \ No newline at end of file diff --git a/build_package.py b/build_package.py deleted file mode 100644 index e7f1a30..0000000 --- a/build_package.py +++ /dev/null @@ -1,39 +0,0 @@ -""" -This script builds a Python package using the `build` module. -It allows for passing additional arguments to the build command and -supports specifying a version number via command line arguments. -It also sets the `PACKAGE_VERSION` environment variable if a version is provided. -""" - -import os -import subprocess -import sys -import re - - -def patch_pyproject_version(version: str): - pyproject_path = "pyproject.toml" - with open(pyproject_path, "r") as f: - content = f.read() - - new_content = re.sub( - r'^version\s*=\s*"[0-9a-zA-Z\.\-\_]+"', - f'version = "{version}"', - content - ) - - with open(pyproject_path, "w") as f: - f.write(new_content) - - -if "--version" in sys.argv: - idx = sys.argv.index("--version") - version = sys.argv[idx + 1] - os.environ["PACKAGE_VERSION"] = version - patch_pyproject_version(version) - sys.argv.pop(idx) # remove --version - sys.argv.pop(idx) # remove version value - -cmd = [sys.executable, "-m", "build"] + sys.argv[1:] -print(" ".join(cmd)) # Print the command for debugging -subprocess.run(cmd, check=True, stderr=sys.stderr, stdout=sys.stdout) diff --git a/get_version.py b/get_version.py deleted file mode 100644 index 11e1915..0000000 --- a/get_version.py +++ /dev/null @@ -1,41 +0,0 @@ - -def install_if_not_installed(package_name, url): - try: - __import__(package_name) - except ImportError: - import subprocess - import sys - subprocess.check_call([sys.executable, "-m", "pip", "install", url]) - - - -install_if_not_installed("version", "https://github.com/T0ine34/python-sample/releases/download/1.0.2/version-1.0.2-py3-none-any.whl") - - -import os - -from version import Version - -branch = os.popen("git rev-parse --abbrev-ref HEAD").read().strip() - -def get_tag(): - tags_list = os.popen("git tag").read().strip().split("\n") - tags = [Version.from_string(t) for t in tags_list if t] - tags.sort(reverse=True) - return tags[0] if tags else Version(0, 0, 0) - -try: - version = Version.from_string(branch) -except ValueError: - version = get_tag() - version.prerelease = "alpha" - - if version.minor >= 1: - version.patch_increment() - else: - version.minor_increment() - -last_commit = os.popen("git rev-parse HEAD").read().strip() - -version.metadata = last_commit -print(version) diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 045d731..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,38 +0,0 @@ -[project] -name = "modular-server-manager-web-client" -version = "0.1.0" -description = "MSM Web Client" -authors = [ - { name = "Antoine BUIREY", email = "antoine.buirey@gmail.com" } -] -requires-python = ">=3.12" -dependencies = [ - "blinker==1.9.0", - "click==8.1.8", - "Flask==3.1.1", - "gamuLogger>=3.2.4", - "itsdangerous==2.2.0", - "Jinja2==3.1.6", - "MarkupSafe==3.0.2", - "typing_extensions==4.13.2", - "urllib3==2.5.0", - "Werkzeug==3.1.3", - "dnspython==2.7.0", - "eventlet==0.40.3", - "greenlet==3.2.1", - "python-socketio==5.14.0", - "http_code @ https://github.com/T0ine34/python-sample/releases/download/1.1.6/http_code-1.1.6-py3-none-any.whl", - "singleton @ https://github.com/T0ine34/python-sample/releases/download/1.1.6/singleton-1.1.6-py3-none-any.whl", - "version @ https://github.com/T0ine34/python-sample/releases/download/1.1.6/version-1.1.6-py3-none-any.whl", - "modular-server-manager @ https://github.com/modular-server-manager/server/releases/download/0.1.4/modular_server_manager-0.1.4-py3-none-any.whl" -] - -[tool.setuptools] -packages = ["modular_server_manager_web_client"] - -package-data = { "modular_server_manager_web_client" = [ - "client/*", - "client/**/*", - "compatibility.json", -] } -include-package-data = true \ No newline at end of file diff --git a/src/web/index.html b/src/index.html similarity index 100% rename from src/web/index.html rename to src/index.html diff --git a/src/web/main.ts b/src/main.ts similarity index 100% rename from src/web/main.ts rename to src/main.ts diff --git a/src/web/scripts/api.ts b/src/scripts/api.ts similarity index 100% rename from src/web/scripts/api.ts rename to src/scripts/api.ts diff --git a/src/web/scripts/app.ts b/src/scripts/app.ts similarity index 100% rename from src/web/scripts/app.ts rename to src/scripts/app.ts diff --git a/src/web/scripts/cookie.ts b/src/scripts/cookie.ts similarity index 100% rename from src/web/scripts/cookie.ts rename to src/scripts/cookie.ts diff --git a/src/web/scripts/types.ts b/src/scripts/types.ts similarity index 100% rename from src/web/scripts/types.ts rename to src/scripts/types.ts diff --git a/src/server/__init__.py b/src/server/__init__.py deleted file mode 100644 index 43eec77..0000000 --- a/src/server/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .web_server import WebServer as Interface diff --git a/src/server/compatibility.json b/src/server/compatibility.json deleted file mode 100644 index 70d396b..0000000 --- a/src/server/compatibility.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/AntoineBuirey/xml-json-schema/refs/tags/1.2.3/modular-server-manager/compatibility.schema", - "compatibility": { - "0.1.3": { - "min_module_version": "0.1.0", - "max_module_version": "0.1.0" - }, - "0.1.4": { - "min_module_version": "0.1.0", - "max_module_version": "0.1.0" - }, - "0.1.5": { - "min_module_version": "0.1.0", - "max_module_version": "0.1.0" - } - } -} diff --git a/src/server/http_server.py b/src/server/http_server.py deleted file mode 100644 index 455f056..0000000 --- a/src/server/http_server.py +++ /dev/null @@ -1,556 +0,0 @@ -# pyright: reportUnusedFunction=false -# pyright: reportMissingTypeStubs=false - -import html -import os -import pathlib -import traceback -from typing import Any, Callable, TypeVar, Union - -from flask import Flask, request -from gamuLogger import Logger -from http_code import HttpCode as HTTP -from version import Version - -from .utils import str2bool, guess_type, RE_MC_SERVER_NAME -from modular_server_manager import BaseInterface, AccessLevel - -Logger.set_module("User Interface.Http Server") - -STATIC_PATH = os.path.abspath(__file__) + "/client" - -T = TypeVar('T') - -# type JsonAble = dict[str, JsonAble] | list[JsonAble] | str | int | float | bool | None -JsonAble = Union[dict[str, Any], list[Any], str, int, float, bool, None] - -FlaskReturnData = ( - tuple[JsonAble, int, dict[str, str]] | # data, status code, headers - tuple[JsonAble, int] | # data, status code - tuple[JsonAble] | # data - JsonAble | # data - - tuple[str, int, dict[str, str]] | # string, status code, headers - tuple[str, int] | # string, status code - tuple[str] | # string - str # string -) - -class HttpServer(BaseInterface): - def __init__(self, *args: Any, port: int, **kwargs: Any): - Logger.trace("Initializing HttpServer") - BaseInterface.__init__(self, *args, **kwargs) - self._port = port - self.__app = Flask(__name__) - - self.__config_api_route() - self.__config_static_route() - - def _get_app(self): - """ - Get the Flask app instance. - - :return: The Flask app instance. - """ - return self.__app - - def request_auth(self, access_level: AccessLevel) -> Callable[[T], T]: - """ - Decorator to check if the user has the required access level. - - Can expose the token, server name and user object to the function. - - token: the token used to authenticate the user - - server: the server name passed in the request - - user: the user object associated with the token - - **The type hints for the function must be set for the decorator to work properly.** - - :param access_level: Required access level. - """ - def decorator(f : Callable[[Any], FlaskReturnData] | Callable[[], FlaskReturnData]) -> Callable[[Any], FlaskReturnData] | Callable[[], FlaskReturnData]: - def wrapper(*args: Any, **kwargs: Any) -> FlaskReturnData: - Logger.info(f"Request from {request.remote_addr} with method {request.method} for path {request.path}") - try: - if 'Authorization' not in request.headers: - Logger.info("Missing Authorization header") - return {"message": "Missing parameters"}, HTTP.BAD_REQUEST - token = request.headers.get('Authorization') - if not token: - Logger.info("Missing Authorization header") - return {"message": "Missing parameters"}, HTTP.BAD_REQUEST - if not token.startswith("Bearer "): - Logger.info("Invalid Authorization header format") - return {"message": "Invalid token"}, HTTP.UNAUTHORIZED - token = token[7:] - if not token or not self._database.exist_user_token(token): - Logger.info("Invalid token") - return {"message": "Invalid token"}, HTTP.UNAUTHORIZED - - access_token = self._database.get_user_token_by_token(token) - if not access_token or not access_token.is_valid(): - Logger.info("Invalid token") - return {"message": "Invalid token"}, HTTP.UNAUTHORIZED - - user = self._database.get_user(access_token.username) - if user.access_level < access_level: - Logger.info(f"User {user.username} does not have the required access level") - return {"message": "Forbidden"}, HTTP.FORBIDDEN - except Exception as e: - Logger.error(f"Error processing request: {e}") - Logger.debug(f"Error details: {traceback.format_exc()}") - return {"message": "Internal Server Error"}, HTTP.INTERNAL_SERVER_ERROR - else: - Logger.info(f"User {user.username} has the required access level") - additional_args = {} - if "token" in f.__annotations__: - additional_args["token"] = token - if "user" in f.__annotations__: - additional_args["user"] = user - return f(*args, **kwargs, **additional_args) - - wrapper.__name__ = f.__name__ - return wrapper - - return decorator # type: ignore - - def __config_static_route(self): - self.__app.static_folder = STATIC_PATH - Logger.debug(f"Configuring static route with STATIC_PATH: {STATIC_PATH}") - - @self.__app.route('/') - def root(): - # redirect to the app index - Logger.trace("asking for index, redirecting to /app/") - return "/redirecting to /app/", HTTP.PERMANENT_REDIRECT, {'Location': '/app/'} - - @self.__app.route('/app/') #pyright: ignore[reportArgumentType, reportUntypedFunctionDecorator] - def index(): - Logger.trace("asking for index.html, redirecting to /app/dashboard") - return static_proxy('dashboard') - - @self.__app.route('/app/') #pyright: ignore[reportArgumentType, reportUntypedFunctionDecorator] - def static_proxy(path : str): - try: - # Validate the path to prevent directory traversal attacks - if ".." in path or path.startswith("/"): - Logger.trace(f"Invalid path: {path}") - return "Invalid path", HTTP.BAD_REQUEST - - # send the file to the browser - Logger.trace(f"requesting {STATIC_PATH}/{path}") - # Normalize the path and ensure it is within STATIC_PATH - full_path = os.path.normpath(os.path.join(STATIC_PATH, path)) - if not full_path.startswith(STATIC_PATH): - Logger.trace(f"Invalid path traversal attempt: {path}") - return "Invalid path", HTTP.BAD_REQUEST - - if not os.path.exists(full_path): - if os.path.exists(f"{full_path}.html"): - full_path = f"{full_path}.html" - # elif full_path.endswith('.css') or full_path.endswith('.js'): - elif any(full_path.endswith(ext) for ext in ['.css', '.js', '.css.map']): - # /client/login.js -> /client/login/login.js - filename = '.'.join(os.path.basename(full_path).split('.')[:-1]) - full_path = os.path.join(os.path.dirname(full_path), filename, os.path.basename(full_path)) - if not os.path.exists(full_path): - Logger.trace(f"File not found: {full_path}") - return "File not found", HTTP.NOT_FOUND - else: - Logger.trace(f"File not found: {full_path}") - return "File not found", HTTP.NOT_FOUND - - if os.path.isdir(full_path): - # If the path is a directory, serve the index.html file inside it - index_file = os.path.join(full_path, 'index.html') - if os.path.exists(index_file): - full_path = index_file - else: - Logger.trace(f"Directory requested without index file: {full_path}") - return "Directory requested without index file", HTTP.BAD_REQUEST - Logger.trace(f"Serving file: {full_path}") - - content = pathlib.Path(full_path).read_bytes() - mimetype = guess_type(full_path) - # Only allow known-safe mimetypes - Logger.trace(f"Serving {STATIC_PATH}/{path} ({len(content)} bytes) with mimetype {mimetype})") - return content, HTTP.OK, {'Content-Type': mimetype} - except Exception as e: - Logger.error(f"Error serving file {path}: {e}") - return "Internal Server Error", HTTP.INTERNAL_SERVER_ERROR - - @self.__app.route('/') #pyright: ignore[reportArgumentType, reportUntypedFunctionDecorator] - def static_fallback(path: str): - """ - Fallback route for static files. - This will serve files from the static folder if they exist. - """ - Logger.trace(f"Fallback for static file: {path}") - return static_proxy(path) - - def __config_api_route(self): - self.__config_api_route_user() - self.__config_api_route_server() - - - -################################################################################################### -# SERVER RELATED ENDPOINTS -# region: server -################################################################################################### - def __config_api_route_server(self): - - @self.__app.route('/api/mc_versions', methods=['GET']) #pyright: ignore[reportArgumentType, reportUntypedFunctionDecorator] - @self.request_auth(AccessLevel.USER) - def list_mc_versions() -> FlaskReturnData: - Logger.trace(f"API request for path: {request.path}") - try: - versions = self.list_mc_versions() - return {"versions": [str(version) for version in versions]}, HTTP.OK - except Exception as e: - Logger.error(f"Error processing API request for path {request.path}: {e}") - Logger.debug(f"Error details: {traceback.format_exc()}") - return {"message": "Internal Server Error"}, HTTP.INTERNAL_SERVER_ERROR - - @self.__app.route('/api/forge_versions/', methods=['GET']) #pyright: ignore[reportArgumentType, reportUntypedFunctionDecorator] - @self.request_auth(AccessLevel.USER) - def list_forge_versions(mc_version: str) -> FlaskReturnData: - Logger.trace(f"API request for path: {request.path}") - try: - if not Version.is_valid_string(mc_version): - Logger.trace(f"Invalid mc_version: {mc_version}") - return {"message": "Invalid mc_version"}, HTTP.BAD_REQUEST - mc_version_v = Version.from_string(mc_version) - versions = self.list_forge_versions(mc_version_v) - return {"versions": [str(version) for version in versions]}, HTTP.OK - except Exception as e: - Logger.error(f"Error processing API request for path {request.path}: {e}") - Logger.debug(f"Error details: {traceback.format_exc()}") - return {"message": "Internal Server Error"}, HTTP.INTERNAL_SERVER_ERROR - - @self.__app.route('/api/servers', methods=['GET']) #pyright: ignore[reportArgumentType, reportUntypedFunctionDecorator] - @self.request_auth(AccessLevel.USER) - def list_servers(token : str) -> FlaskReturnData: - Logger.trace(f"API request for path: {request.path}") - try: - servers = self.list_servers() - result = [] - for server in servers: - for key, item in server.items(): - if isinstance(item, Version): - server[key] = str(item) - result.append(server) - return result - except ValueError as ve: - Logger.debug(f"Error processing API request for path {request.path}: {ve}") - return {"message": "Bad Request"}, HTTP.BAD_REQUEST - except Exception as e: - Logger.error(f"Error processing API request for path {request.path}: {e}") - Logger.debug(f"Error details: {traceback.format_exc()}") - return {"message": "Internal Server Error"}, HTTP.INTERNAL_SERVER_ERROR - - @self.__app.route('/api/server/', methods=['GET']) #pyright: ignore[reportArgumentType, reportUntypedFunctionDecorator] - @self.request_auth(AccessLevel.USER) - def get_server_info(server_name: str) -> FlaskReturnData: - Logger.trace(f"API request for path: {request.path}") - try: - info = self.get_server_info(server_name) - for key, item in info.items(): - if isinstance(item, Version): - info[key] = str(item) - return info, HTTP.OK - except ValueError as ve: - Logger.debug(f"Error processing API request for path {request.path}: {ve}") - return {"message": "Bad Request"}, HTTP.BAD_REQUEST - except Exception as e: - Logger.error(f"Error processing API request for path {request.path}: {e}") - Logger.debug(f"Error details: {traceback.format_exc()}") - return {"message": "Internal Server Error"}, HTTP.INTERNAL_SERVER_ERROR - - @self.__app.route('/api/list_mc_server_dirs', methods=['GET']) #pyright: ignore[reportArgumentType, reportUntypedFunctionDecorator] - @self.request_auth(AccessLevel.USER) - def list_mc_server_dirs(token : str) -> FlaskReturnData: - Logger.trace(f"API request for path: {request.path}") - try: - dirs = self.list_mc_server_dirs() - return {"dirs": dirs}, HTTP.OK - except Exception as e: - Logger.error(f"Error processing API request for path {request.path}: {e}") - Logger.debug(f"Error details: {traceback.format_exc()}") - return {"message": "Internal Server Error"}, HTTP.INTERNAL_SERVER_ERROR - - @self.__app.route('/api/create_server', methods=['POST']) #pyright: ignore[reportArgumentType, reportUntypedFunctionDecorator] - @self.request_auth(AccessLevel.OPERATOR) - def create_new_server() -> FlaskReturnData: - Logger.trace(f"API request for path: {request.path}") - try: - data : dict[str, JsonAble] = request.get_json() - server_name = data.get("name") - server_type = data.get("type") - server_path = data.get("path") - - autostart = data.get("autostart", False) - - mc_version = data.get("mc_version") - modloader_version = data.get("modloader_version") - ram = data.get("ram") - - if not server_name or not isinstance(server_name, str) or not RE_MC_SERVER_NAME.match(server_name): - Logger.debug("Invalid server name") - return {"message": "Invalid parameters"}, HTTP.BAD_REQUEST - server_name = html.escape(server_name.strip()) - if not server_type or not isinstance(server_type, str): - Logger.debug("Invalid server type") - return {"message": "Invalid parameters"}, HTTP.BAD_REQUEST - - if not server_path or not isinstance(server_path, str): - Logger.debug("Invalid server path") - return {"message": "Invalid parameters"}, HTTP.BAD_REQUEST - server_path = html.escape(server_path.strip()) - if not mc_version or not isinstance(mc_version, str): - Logger.debug("Invalid mc_version") - return {"message": "Invalid parameters"}, HTTP.BAD_REQUEST - mc_version = Version.from_string(mc_version) - if server_type != "vanilla" and not modloader_version or not isinstance(modloader_version, str): - Logger.debug("Invalid modloader_version") - return {"message": "Invalid parameters"}, HTTP.BAD_REQUEST - if not modloader_version: - Logger.debug("Modloader version is required for non-vanilla servers") - return {"message": "Invalid parameters"}, HTTP.BAD_REQUEST - modloader_version = Version.from_string(modloader_version) - if not isinstance(autostart, bool): - Logger.debug("Invalid autostart value") - return {"message": "Invalid parameters"}, HTTP.BAD_REQUEST - if not isinstance(ram, int) or ram <= 0: - Logger.debug("Invalid RAM value") - return {"message": "Invalid parameters"}, HTTP.BAD_REQUEST - - self.create_server( - name=server_name, - type=server_type, - path=server_path, - autostart=autostart, - mc_version=mc_version, - modloader_version=modloader_version, - ram=ram - ) - except ValueError as ve: - Logger.debug(f"Error creating server: {ve}") - return {"message": "Bad Request"}, HTTP.BAD_REQUEST - except Exception as e: - Logger.error(f"Error creating server: {e}") - Logger.debug(f"Error details: {traceback.format_exc()}") - return {"message": "Internal Server Error"}, HTTP.INTERNAL_SERVER_ERROR - - @self.__app.route('/api/start_server/', methods=['POST']) #pyright: ignore[reportArgumentType, reportUntypedFunctionDecorator] - @self.request_auth(AccessLevel.ADMIN) - def start_server(server_name: str) -> FlaskReturnData: - Logger.trace(f"API request for path: {request.path}") - try: - self.start_server(server_name) - return {"message": "Server started"}, HTTP.OK - except ValueError as ve: - Logger.debug(f"Start server error: {ve}") - return {"message": "Bad Request"}, HTTP.BAD_REQUEST - except Exception as e: - Logger.error(f"Error starting server: {e}") - Logger.debug(f"Error details: {traceback.format_exc()}") - return {"message": "Internal Server Error"}, HTTP.INTERNAL_SERVER_ERROR - - @self.__app.route('/api/stop_server/', methods=['POST']) #pyright: ignore[reportArgumentType, reportUntypedFunctionDecorator] - @self.request_auth(AccessLevel.ADMIN) - def stop_server(server_name: str) -> FlaskReturnData: - Logger.trace(f"API request for path: {request.path}") - try: - self.stop_server(server_name) - return {"message": "Server stopped"}, HTTP.OK - except ValueError as ve: - Logger.debug(f"Stop server error: {ve}") - return {"message": "Bad Request"}, HTTP.BAD_REQUEST - except Exception as e: - Logger.error(f"Error stopping server: {e}") - Logger.debug(f"Error details: {traceback.format_exc()}") - return {"message": "Internal Server Error"}, HTTP.INTERNAL_SERVER_ERROR - - @self.__app.route('/api/restart_server/', methods=['POST']) #pyright: ignore[reportArgumentType, reportUntypedFunctionDecorator] - @self.request_auth(AccessLevel.ADMIN) - def restart_server(server_name: str) -> FlaskReturnData: - Logger.trace(f"API request for path: {request.path}") - try: - self.restart_server(server_name) - return {"message": "Server restarted"}, HTTP.OK - except ValueError as ve: - Logger.debug(f"Restart server error: {ve}") - return {"message": "Bad Request"}, HTTP.BAD_REQUEST - except Exception as e: - Logger.error(f"Error restarting server: {e}") - Logger.debug(f"Error details: {traceback.format_exc()}") - return {"message": "Internal Server Error"}, HTTP.INTERNAL_SERVER_ERROR - -################################################################################################### -# endregion: server -# USER RELATED ENDPOINTS -# region: user -################################################################################################### - def __config_api_route_user(self): - - @self.__app.route('/api/login', methods=['POST']) #pyright: ignore[reportArgumentType] - def login() -> FlaskReturnData: - Logger.trace(f"API request for path: {request.path}") - try: - data = request.get_json() - username = data.get('username', None) - password = data.get('password', None) - remember = str2bool(data.get('remember', 'false')) - token = self.login(username, password, remember) - return {"token": token.token}, HTTP.OK - except ValueError as ve: - Logger.debug(f"Login error: {ve}") - return {"message": "Bad Request"}, HTTP.BAD_REQUEST - except Exception as e: - Logger.error(f"Error processing login request: {e}") - Logger.debug(f"Error details: {traceback.format_exc()}") - return {"message": "Internal Server Error"}, HTTP.INTERNAL_SERVER_ERROR - - @self.__app.route('/api/register', methods=['POST']) #pyright: ignore[reportArgumentType] - def register() -> FlaskReturnData: - Logger.debug(f"API request for path: {request.path}") - Logger.trace(request.get_json()) - try: - data = request.get_json() - username = data.get('username', None) - password = data.get('password', None) - remember = str2bool(data.get('remember', 'false')) - token = self.register(username, password, remember) - return { "token": token.token }, HTTP.CREATED - except ValueError as ve: - Logger.debug(f"Register error: {ve}") - return {"message": "Bad Request"}, HTTP.BAD_REQUEST - except Exception as e: - Logger.error(f"Error processing register request: {e}") - Logger.debug(f"Error details: {traceback.format_exc()}") - return {"message": "Internal Server Error"}, HTTP.INTERNAL_SERVER_ERROR - - @self.__app.route('/api/logout', methods=['POST']) #pyright: ignore[reportArgumentType] - @self.request_auth(AccessLevel.USER) - def logout(token: str) -> FlaskReturnData: - Logger.trace(f"API request for path: {request.path}") - try: - self.logout(token) - return {"message": "Logged out"}, HTTP.OK - except ValueError as ve: - Logger.debug(f"Logout error: {ve}") - return {"message": "Bad Request"}, HTTP.BAD_REQUEST - except Exception as e: - Logger.error(f"Error processing logout request: {e}") - Logger.debug(f"Error details: {traceback.format_exc()}") - return {"message" : "Internal Server Error"}, HTTP.INTERNAL_SERVER_ERROR - - @self.__app.route('/api/delete_user', methods=['POST']) - @self.request_auth(AccessLevel.USER) - def delete_user(token: str): # delete the user associated with the token - Logger.trace(f"API request for path: {request.path}") - try: - self.delete_user(token) - return {"message": "User deleted"}, HTTP.OK - except ValueError as ve: - Logger.debug(f"Delete user error: {ve}") - return {"message": "Bad Request"}, HTTP.BAD_REQUEST - except Exception as e: - Logger.error(f"Error processing delete user request: {e}") - Logger.debug(f"Error details: {traceback.format_exc()}") - return {"message": "Internal Server Error"}, HTTP.INTERNAL_SERVER_ERROR - - @self.__app.route('/api/user', methods=['GET']) - @self.request_auth(AccessLevel.USER) - def get_user_info(token : str): - Logger.trace(f"API request for path: {request.path}") - try: - user = self.get_user_info(token) - return { - "username": user.username, - "access_level": user.access_level.name, - "registered_at": user.registered_at.strftime("%d/%m/%Y, %H:%M:%S"), - "last_login": user.last_login.strftime("%d/%m/%Y, %H:%M:%S") - }, HTTP.OK - except ValueError as ve: - Logger.debug(f"Get user info error: {ve}") - return {"message": "Bad Request"}, HTTP.BAD_REQUEST - except Exception as e: - Logger.error(f"Error processing user info request: {e}") - Logger.debug(f"Error details: {traceback.format_exc()}") - return {"message": "Internal Server Error"}, HTTP.INTERNAL_SERVER_ERROR - - @self.__app.route('/api/user/update_password', methods=['POST']) - @self.request_auth(AccessLevel.USER) - def update_password(token: str): # update the password of the user associated with the token - Logger.trace(f"API request for path: {request.path}") - try: - data = request.get_json() - password = data.get('password', None) - self.update_password(token, password) - return {"message": "User updated"}, HTTP.OK - except ValueError as ve: - Logger.debug(f"Update password error: {ve}") - return {"message": "Bad Request"}, HTTP.BAD_REQUEST - except Exception as e: - Logger.error(f"Error processing user info request: {e}") - Logger.debug(f"Error details: {traceback.format_exc()}") - return {"message": "Internal Server Error"}, HTTP.INTERNAL_SERVER_ERROR - - @self.__app.route('/api/user/', methods=['GET']) - @self.request_auth(AccessLevel.OPERATOR) - def get_user_info_by_username(username: str): - Logger.trace(f"API request for path: {request.path}") - try: - user = self.get_user_info_by_username(username) - return { - "username": user.username, - "access_level": user.access_level.name, - "registered_at": user.registered_at.strftime("%d/%m/%Y, %H:%M:%S"), - "last_login": user.last_login.strftime("%d/%m/%Y, %H:%M:%S") - }, HTTP.OK - except ValueError as ve: - Logger.debug(f"Get user info by username error: {ve}") - return {"message": "Bad Request"}, HTTP.BAD_REQUEST - except Exception as e: - Logger.error(f"Error processing user info request: {e}") - Logger.debug(f"Error details: {traceback.format_exc()}") - return {"message": "Internal Server Error"}, HTTP.INTERNAL_SERVER_ERROR - - @self.__app.route('/api/user//global_access', methods=['POST']) - @self.request_auth(AccessLevel.OPERATOR) - def update_user_global_access(username: str): # update the global access level of the user - Logger.trace(f"API request for path: {request.path}") - try: - data = request.get_json() - access_level = data.get('access_level', None) - self.update_user_access(username, access_level) - return {"message": "User updated"}, HTTP.OK - except ValueError as ve: - Logger.debug(f"Update user global access error: {ve}") - return {"message": "Bad Request"}, HTTP.BAD_REQUEST - except Exception as e: - Logger.error(f"Error processing user info request: {e}") - Logger.debug(f"Error details: {traceback.format_exc()}") - return {"message": "Internal Server Error"}, HTTP.INTERNAL_SERVER_ERROR - - @self.__app.route('/api/user//password', methods=['POST']) - @self.request_auth(AccessLevel.OPERATOR) - def update_user_password(username: str): - Logger.trace(f"API request for path: {request.path}") - try: - data = request.get_json() - password = data.get('password', None) - self.update_user_password(username, password) - return {"message": "User updated"}, HTTP.OK - except ValueError as ve: - Logger.debug(f"Update user password error: {ve}") - return {"message": "Bad Request"}, HTTP.BAD_REQUEST - except Exception as e: - Logger.error(f"Error processing user info request: {e}") - Logger.debug(f"Error details: {traceback.format_exc()}") - return {"message": "Internal Server Error"}, HTTP.INTERNAL_SERVER_ERROR - -################################################################################################### -# endregion: user -################################################################################################### diff --git a/src/server/utils.py b/src/server/utils.py deleted file mode 100644 index 553ca03..0000000 --- a/src/server/utils.py +++ /dev/null @@ -1,48 +0,0 @@ -import re - -from gamuLogger import Logger - -Logger.set_module("User Interface.Utils") - -RE_MC_SERVER_NAME = re.compile(r"^[a-zA-Z0-9_]{1,16}$") # Matches Minecraft server names (1-16 characters, letters, numbers, underscores) - -def str2bool(v : str) -> bool: - """ - Convert a string to a boolean value. - """ - if isinstance(v, bool): - return v - if v.lower() in {'yes', 'true', 't', '1'}: - return True - if v.lower() in {'no', 'false', 'f', '0'}: - return False - raise ValueError(f"Invalid boolean string: {v}") - -class NoLog: - def write(self, *_): pass - def flush(self): pass - -def guess_type(filename: str) -> str: - """ - Guess the MIME type of a file based on its extension. - """ - mimetypes = { - 'html': 'text/html', - 'css': 'text/css', - 'js': 'application/javascript', - 'json': 'application/json', - 'png': 'image/png', - 'jpeg': 'image/jpeg', - 'gif': 'image/gif', - 'svg': 'image/svg+xml', - 'webp': 'image/webp', - 'woff': 'font/woff', - 'woff2': 'font/woff2', - 'ttf': 'font/ttf', - 'otf': 'font/otf' - } - ext = filename.split('.')[-1].lower() - if ext not in mimetypes: - Logger.warning(f"Unknown file extension: {ext}, defaulting to application/octet-stream") - return 'application/octet-stream' - return mimetypes[ext] \ No newline at end of file diff --git a/src/server/web_server.py b/src/server/web_server.py deleted file mode 100644 index 3648b2a..0000000 --- a/src/server/web_server.py +++ /dev/null @@ -1,163 +0,0 @@ -import sys -from datetime import datetime - -import eventlet -import socketio -from eventlet import wsgi -from gamuLogger import Logger -from version import Version -from typing import Any - -from .utils import NoLog -from .http_server import HttpServer -from .websocket_server import WebSocketServer -from modular_server_manager import UserInterfaceModules - -Logger.set_module("User Interface.Web Server") - -class WebServer(HttpServer, WebSocketServer): - def __init__(self, *args: Any, **kwargs: Any): - Logger.trace("Initializing WebServer") - HttpServer.__init__(self, - *args, - **kwargs - ) - WebSocketServer.__init__(self, - *args, - **kwargs - ) - - def start(self): - super().start() - Logger.info(f"Starting HTTP server on port {self._port}") - try: - app = socketio.WSGIApp(self._get_sio(), self._get_app()) - wsgi.server(eventlet.listen(('', self._port), reuse_addr=True), app, log=NoLog()) - except KeyboardInterrupt: - Logger.info("HTTP server stopped by user") - except Exception as e: - Logger.fatal(f"WebServer encountered an error: {e}") - sys.exit(1) - finally: - sys.stdout.write("\r") - sys.stdout.flush() - Logger.info("Stopping HTTP server...") - Logger.info("HTTP server stopped") - - def stop(self): - Logger.info("Stopping WebServer...") - HttpServer.stop(self) - WebSocketServer.stop(self) - Logger.info("WebServer stopped") - - -####################################### EVENT TRANSMISSION ######################################## - - def on_server_starting(self, timestamp: datetime, server_name: str) -> None: - self.send("server_starting", { - "timestamp": timestamp.isoformat(), - "server_name": server_name - }) - - def on_server_started(self, timestamp: datetime, server_name: str) -> None: - self.send("server_started", { - "timestamp": timestamp.isoformat(), - "server_name": server_name - }) - - def on_server_stopping(self, timestamp: datetime, server_name: str) -> None: - self.send("server_stopping", { - "timestamp": timestamp.isoformat(), - "server_name": server_name - }) - - def on_server_stopped(self, timestamp: datetime, server_name: str) -> None: - self.send("server_stopped", { - "timestamp": timestamp.isoformat(), - "server_name": server_name - }) - - def on_server_crashed(self, timestamp: datetime, server_name: str) -> None: - self.send("server_crashed", { - "timestamp": timestamp.isoformat(), - "server_name": server_name - }) - - def on_server_created(self, timestamp: datetime, server_name: str, server_type: str, server_path: str, autostart: bool, mc_version: Version, modloader_version: Version, ram: int) -> None: - self.send("server_created", { - "timestamp": timestamp.isoformat(), - "server_name": server_name, - "server_type": server_type, - "server_path": server_path, - "autostart": autostart, - "mc_version": mc_version, - "modloader_version": modloader_version, - "ram": ram - }) - - def on_server_deleted(self, timestamp: datetime, server_name: str) -> None: - self.send("server_deleted", { - "timestamp": timestamp.isoformat(), - "server_name": server_name - }) - - def on_server_renamed(self, timestamp: datetime, old_name: str, new_name: str) -> None: - self.send("server_renamed", { - "timestamp": timestamp.isoformat(), - "old_name": old_name, - "new_name": new_name - }) - - def on_console_message_received(self, timestamp: datetime, server_name: str, message: str) -> None: - self.send("console_message_received", { - "timestamp": timestamp.isoformat(), - "server_name": server_name, - "message": message - }) - - def on_console_log_received(self, timestamp: datetime, server_name: str, log: str) -> None: - self.send("console_log_received", { - "timestamp": timestamp.isoformat(), - "server_name": server_name, - "log": log - }) - - def on_player_joined(self, timestamp: datetime, server_name: str, player_name: str) -> None: - self.send("player_joined", { - "timestamp": timestamp.isoformat(), - "server_name": server_name, - "player_name": player_name - }) - - def on_player_left(self, timestamp: datetime, server_name: str, player_name: str) -> None: - self.send("player_left", { - "timestamp": timestamp.isoformat(), - "server_name": server_name, - "player_name": player_name - }) - - def on_player_kicked(self, timestamp: datetime, server_name: str, player_name: str, reason: str) -> None: - self.send("player_kicked", { - "timestamp": timestamp.isoformat(), - "server_name": server_name, - "player_name": player_name, - "reason": reason - }) - - def on_player_banned(self, timestamp: datetime, server_name: str, player_name: str, reason: str) -> None: - self.send("player_banned", { - "timestamp": timestamp.isoformat(), - "server_name": server_name, - "player_name": player_name, - "reason": reason - }) - - def on_player_pardoned(self, timestamp: datetime, server_name: str, player_name: str) -> None: - self.send("player_pardoned", { - "timestamp": timestamp.isoformat(), - "server_name": server_name, - "player_name": player_name - }) - - -UserInterfaceModules['web'] = WebServer \ No newline at end of file diff --git a/src/server/websocket_server.py b/src/server/websocket_server.py deleted file mode 100644 index df1a290..0000000 --- a/src/server/websocket_server.py +++ /dev/null @@ -1,45 +0,0 @@ -import socketio -from gamuLogger import Logger -from typing import Any - -from modular_server_manager import BaseInterface - -Logger.set_module("User Interface.WebSock Server") - -class WebSocketServer(BaseInterface): - def __init__(self, *args: Any, **kwargs: Any): - Logger.trace("Initializing WebSocketServer") - BaseInterface.__init__(self, - *args, - **kwargs - ) - self.__sio = socketio.Server(cors_allowed_origins='*') - self.__config_routes() - - def __config_routes(self): - @self.__sio.event - def connect(sid, environ): - Logger.info(f"Client connected: {sid}") - - @self.__sio.event - def disconnect(sid): - Logger.info(f"Client disconnected: {sid}") - - def send(self, event: str, data: dict[str, Any]): - """ - Send a message to all connected clients. - - :param event: The event name to send. - :param data: The data to send with the event. - """ - self.__sio.emit(event, data) - Logger.debug(f"Sent event '{event}' with data: {data}") - - - def _get_sio(self): - """ - Get the SocketIO server instance. - - :return: The SocketIO server instance. - """ - return self.__sio diff --git a/src/web/styles/main.scss b/src/styles/main.scss similarity index 100% rename from src/web/styles/main.scss rename to src/styles/main.scss From cf7d39296eac025e3ed6a6ea06293bb8fc2f0ace Mon Sep 17 00:00:00 2001 From: Antoine <91411073+T0ine34@users.noreply.github.com> Date: Mon, 26 Jan 2026 08:52:42 +0100 Subject: [PATCH 8/8] Refactor build process and add initial HTML, CSS, and JavaScript files for web client --- makefile | 41 ++++++++++--------- .../client/assets/app.js | 35 ++++++++++++++++ .../client/assets/css/main.css | 29 +++++++++++++ .../client/index.html | 18 ++++++++ package.json | 6 +-- 5 files changed, 107 insertions(+), 22 deletions(-) create mode 100644 modular_server_manager_web_client/client/assets/app.js create mode 100644 modular_server_manager_web_client/client/assets/css/main.css create mode 100644 modular_server_manager_web_client/client/index.html diff --git a/makefile b/makefile index eeb18b7..96d70fa 100644 --- a/makefile +++ b/makefile @@ -28,11 +28,11 @@ VERSION_STR = $(shell echo $(VERSION) | sed "s/-dev-[a-z0-9]*//; s/-dev+.*//") WHEEL = modular_server_manager_web_client-$(VERSION_STR)-py3-none-any.whl ARCHIVE = modular_server_manager_web_client-$(VERSION_STR).tar.gz -SRV_SRC_DIR = src/server/ -SRV_SRC = $(shell find $(SRV_SRC_DIR) -type f -name "*.py") $(SRV_SRC_DIR)compatibility.json -SRV_DIST = $(patsubst $(SRV_SRC_DIR)%,$(BUILD_DIR)%,$(SRV_SRC)) +# SRV_SRC_DIR = src/server/ +# SRV_SRC = $(shell find $(SRV_SRC_DIR) -type f -name "*.py") $(SRV_SRC_DIR)compatibility.json +# SRV_DIST = $(patsubst $(SRV_SRC_DIR)%,$(BUILD_DIR)%,$(SRV_SRC)) -WEB_SRC_DIR = src/web/ +WEB_SRC_DIR = src/ WEB_SRC = $(shell find $(WEB_SRC_DIR) -type f -name "*.html" -o -name "*.scss" -o -name "*.ts") WEB_DIST = $(WEB_BUILD_DIR)/index.html $(WEB_BUILD_DIR)/assets/css/main.css $(WEB_BUILD_DIR)/assets/app.js @@ -43,21 +43,21 @@ print-%: dist: mkdir -p dist -dist/$(WHEEL): $(SRV_DIST) $(PYPROJECT) $(WEB_DIST) $(PYTHON_LIB)/build dist - mkdir -p $(TEMP_DIR) - $(PYTHON) build_package.py --outdir $(TEMP_DIR) --wheel --version $(VERSION_STR) - mkdir -p dist - mv $(TEMP_DIR)/*.whl dist/$(WHEEL) - rm -rf $(TEMP_DIR) - @echo "Building wheel package complete." - -dist/$(ARCHIVE): $(SRV_DIST) $(PYPROJECT) $(WEB_DIST) $(PYTHON_LIB)/build dist - mkdir -p $(TEMP_DIR) - $(PYTHON) build_package.py --outdir $(TEMP_DIR) --sdist --version $(VERSION_STR) - mkdir -p dist - mv $(TEMP_DIR)/*.tar.gz dist/$(ARCHIVE) - rm -rf $(TEMP_DIR) - @echo "Building archive package complete." +# dist/$(WHEEL): $(WEB_DIST) # $(SRV_DIST) $(PYPROJECT) $(PYTHON_LIB)/build dist +# mkdir -p $(TEMP_DIR) +# $(PYTHON) -m build --outdir $(TEMP_DIR) --wheel +# mkdir -p dist +# mv $(TEMP_DIR)/*.whl dist/$(WHEEL) +# rm -rf $(TEMP_DIR) +# @echo "Building wheel package complete." + +# dist/$(ARCHIVE): $(WEB_DIST) # $(SRV_DIST) $(PYPROJECT) $(PYTHON_LIB)/build dist +# mkdir -p $(TEMP_DIR) +# $(PYTHON) build_package.py --outdir $(TEMP_DIR) --sdist --version $(VERSION_STR) +# mkdir -p dist +# mv $(TEMP_DIR)/*.tar.gz dist/$(ARCHIVE) +# rm -rf $(TEMP_DIR) +# @echo "Building archive package complete." $(WEB_DIST): $(WEB_SRC) npm run build @@ -74,6 +74,9 @@ $(INSTAL_PATH) : dist/$(WHEEL) @echo "Package installed." +web: $(WEB_DIST) + + build: dist/$(WHEEL) dist/$(ARCHIVE) install: $(INSTAL_PATH) diff --git a/modular_server_manager_web_client/client/assets/app.js b/modular_server_manager_web_client/client/assets/app.js new file mode 100644 index 0000000..5aecf48 --- /dev/null +++ b/modular_server_manager_web_client/client/assets/app.js @@ -0,0 +1,35 @@ +"use strict";(()=>{var s=class{static set(e,t,r){let o=new Date;o.setTime(o.getTime()+r*60*60*1e3);let a="expires="+o.toUTCString();document.cookie=e+"="+t+";"+a+";path=/"}static get(e){let t=e+"=",r=document.cookie.split(";");for(let o=0;oLogin + + + Remember Me + + + `,this.container.appendChild(e);let t=document.getElementById("loginBtn");t==null||t.addEventListener("click",async()=>{let o=document.getElementById("username").value,a=document.getElementById("password").value,i=document.getElementById("rememberMe").checked;try{await c.login(o,a,i)&&this.show_dashboard_window()}catch(l){alert("Login failed: "+l)}});let r=document.getElementById("registerBtn");r==null||r.addEventListener("click",()=>{this.show_register_window()})}show_register_window(){this.hide_header(),this.clean_window();let e=document.createElement("div");e.innerHTML=` +

Register

+ + + + Remember Me + + + `,this.container.appendChild(e);let t=document.getElementById("registerSubmitBtn");t==null||t.addEventListener("click",async()=>{let o=document.getElementById("reg_username").value,a=document.getElementById("reg_password").value,i=document.getElementById("reg_confirm_password").value,l=document.getElementById("reg_rememberMe").checked;if(a!==i){alert("Passwords do not match");return}try{await c.register(o,a,l)?this.show_dashboard_window():alert("Registration failed")}catch(d){alert("Registration failed: "+d)}});let r=document.getElementById("backToLoginBtn");r==null||r.addEventListener("click",()=>{this.show_login_window()})}show_dashboard_window(){this.show_header(),c.get_server_list().then(e=>{console.log("Fetched servers:",e),this.clean_window();let t=document.createElement("div");t.innerHTML=` +

Dashboard

+
+ ${e.map(r=>` +
+

${r.name}

+

Type: ${r.type}

+

Path: ${r.path}

+

Autostart: ${r.autostart}

+

MC Version: ${r.mc_version}

+

Modloader Version: ${r.modloader_version}

+

RAM: ${r.ram} MB

+

Started At: ${r.started_at?r.started_at:"Not started"}

+
+ `).join("")} +
+ `,this.container.appendChild(t)})}set_header_content(){this.header.innerHTML=` +

Server Management Dashboard

+ + `;let e=document.getElementById("logoutBtn");e==null||e.addEventListener("click",()=>{s.erase("token"),this.show_login_window()})}show_header(){this.header.style.display="block"}hide_header(){this.header.style.display="none"}};document.addEventListener("DOMContentLoaded",()=>{new u().start()});})(); diff --git a/modular_server_manager_web_client/client/assets/css/main.css b/modular_server_manager_web_client/client/assets/css/main.css new file mode 100644 index 0000000..4927d69 --- /dev/null +++ b/modular_server_manager_web_client/client/assets/css/main.css @@ -0,0 +1,29 @@ +* { + box-sizing: border-box; +} + +html, body { + height: 100%; + margin: 0; + font-family: Inter, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial; + background: linear-gradient(180deg, #0f172a 0%, rgb(6.9473684211, 10.6526315789, 19.4526315789) 100%); + color: #e6eef8; +} + +#app { + max-width: 900px; + margin: 6rem auto; + padding: 2rem; + background: rgba(255, 255, 255, 0.03); + border-radius: 10px; + box-shadow: 0 6px 24px rgba(2, 6, 23, 0.6); +} + +h1 { + margin: 0 0 0.5rem 0; + color: #60a5fa; +} + +p { + color: #93c5fd; +} diff --git a/modular_server_manager_web_client/client/index.html b/modular_server_manager_web_client/client/index.html new file mode 100644 index 0000000..9f443f4 --- /dev/null +++ b/modular_server_manager_web_client/client/index.html @@ -0,0 +1,18 @@ + + + + + + Web Client — TypeScript + SCSS + + + + +
+
+ + + + + \ No newline at end of file diff --git a/package.json b/package.json index a1bae1d..1b341aa 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,9 @@ "private": true, "description": "Web modular_server_manager_web_client/client (TypeScript + SCSS + SCSS + HTML) — lightweight build using esbuild and sass", "scripts": { - "dev": "concurrently \"esbuild src/web/main.ts --bundle --outfile=src/web/assets/app.js --sourcemap --watch\" \"sass src/web/styles/main.scss src/web/assets/css/main.css --watch\" \"live-server src/web --port=3000 --open=./index.html\"", - "build": "rimraf modular_server_manager_web_client/client && mkdir -p modular_server_manager_web_client/client && sass src/web/styles/main.scss modular_server_manager_web_client/client/assets/css/main.css --no-source-map && esbuild src/web/main.ts --bundle --minify --target=es2017 --outfile=modular_server_manager_web_client/client/assets/app.js && cpy \"src/web/*.html\" modular_server_manager_web_client/client/", - "clean": "rimraf modular_server_manager_web_client/client src/web/assets", + "dev": "concurrently \"esbuild src/main.ts --bundle --outfile=src/assets/app.js --sourcemap --watch\" \"sass src/styles/main.scss src/assets/css/main.css --watch\" \"live-server src/web --port=3000 --open=./index.html\"", + "build": "rimraf modular_server_manager_web_client/client && mkdir -p modular_server_manager_web_client/client && sass src/styles/main.scss modular_server_manager_web_client/client/assets/css/main.css --no-source-map && esbuild src/main.ts --bundle --minify --target=es2017 --outfile=modular_server_manager_web_client/client/assets/app.js && cpy \"src/*.html\" modular_server_manager_web_client/client/", + "clean": "rimraf modular_server_manager_web_client/client src/assets", "format": "prettier --write \"src/web**/*.{ts,scss,html}\"" }, "devDependencies": {