diff --git a/.gitignore b/.gitignore index 0b1157f..d98d7cf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,20 @@ # Node deps node_modules/ +# Python deps +env/ +__pycache__/ + # Build output dist/ -src/assets/ +src/web/assets/ # Editor / OS .vscode/ .idea/ -.DS_Store \ No newline at end of file +.DS_Store + +*.log +*.egg-info/ + +modular_server_manager_web_client/ \ No newline at end of file diff --git a/build_package.py b/build_package.py new file mode 100644 index 0000000..e7f1a30 --- /dev/null +++ b/build_package.py @@ -0,0 +1,39 @@ +""" +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 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 new file mode 100644 index 0000000..eeb18b7 --- /dev/null +++ b/makefile @@ -0,0 +1,92 @@ +.PHONY: all build clean + +all: build + +TEMP_DIR = build + +PYPROJECT = pyproject.toml + + +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 new file mode 100644 index 0000000..045d731 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,38 @@ +[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/server/__init__.py b/src/server/__init__.py new file mode 100644 index 0000000..43eec77 --- /dev/null +++ b/src/server/__init__.py @@ -0,0 +1 @@ +from .web_server import WebServer as Interface diff --git a/src/server/compatibility.json b/src/server/compatibility.json new file mode 100644 index 0000000..70d396b --- /dev/null +++ b/src/server/compatibility.json @@ -0,0 +1,17 @@ +{ + "$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 new file mode 100644 index 0000000..455f056 --- /dev/null +++ b/src/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 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 new file mode 100644 index 0000000..553ca03 --- /dev/null +++ b/src/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/src/server/web_server.py b/src/server/web_server.py new file mode 100644 index 0000000..3648b2a --- /dev/null +++ b/src/server/web_server.py @@ -0,0 +1,163 @@ +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 new file mode 100644 index 0000000..df1a290 --- /dev/null +++ b/src/server/websocket_server.py @@ -0,0 +1,45 @@ +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/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