From 8361f6f54765f59ae6c060e188e7fb8a46255ff2 Mon Sep 17 00:00:00 2001 From: Antoine Martin Date: Tue, 28 Jan 2025 21:49:33 +0700 Subject: [PATCH] split http scripts handling to a new module --- xpra/scripts/server.py | 2 + xpra/server/base.py | 16 +- xpra/server/core.py | 161 +------------------- xpra/server/features.py | 1 + xpra/server/mixins/http.py | 193 ++++++++++++++++++++++++ xpra/server/mixins/stub_server_mixin.py | 5 +- xpra/server/proxy/server.py | 12 +- 7 files changed, 215 insertions(+), 175 deletions(-) create mode 100644 xpra/server/mixins/http.py diff --git a/xpra/scripts/server.py b/xpra/scripts/server.py index f2dba56e94..5bacd86883 100644 --- a/xpra/scripts/server.py +++ b/xpra/scripts/server.py @@ -329,6 +329,7 @@ def impcheck(*modules) -> bool: features.display = opts.windows features.windows = opts.windows and impcheck("codecs") features.rfb = b(opts.rfb_upgrade) and impcheck("server.rfb") + features.http = opts.http_scripts.lower() not in FALSE_OPTIONS def enforce_server_features() -> None: @@ -360,6 +361,7 @@ def enforce_server_features() -> None: "display": "xpra.server.mixins.display,xpra.server.source.display", "windows": "xpra.server.mixins.window,xpra.server.source.windows", "rfb": "xpra.net.rfb,xpra.server.rfb", + "http": "xpra.server.mixins.http", }) may_block_numpy() diff --git a/xpra/server/base.py b/xpra/server/base.py index f1ff4e4854..683b16c962 100644 --- a/xpra/server/base.py +++ b/xpra/server/base.py @@ -15,7 +15,7 @@ from xpra.common import SSH_AGENT_DISPATCH, FULL_INFO, noop, ConnectionMessage from xpra.net.common import ( may_log_packet, is_request_allowed, - ServerPacketHandlerType, PacketType, PacketElement, HttpResponse, + ServerPacketHandlerType, PacketType, PacketElement, ) from xpra.scripts.config import str_to_bool from xpra.os_util import WIN32, gi_import @@ -68,6 +68,9 @@ def get_server_base_classes() -> tuple[type, ...]: if features.network_state: from xpra.server.mixins.networkstate import NetworkStateServer classes.append(NetworkStateServer) + if features.http: + from xpra.server.mixins.http import HttpServer + classes.append(HttpServer) if features.shell: from xpra.server.mixins.shell import ShellServer classes.append(ShellServer) @@ -760,19 +763,12 @@ def _process_lock_toggle(self, proto, packet: PacketType) -> None: log("lock set to %s for client %i", ss.lock, ss.counter) ###################################################################### - # http server and http audio stream: + # add clients to http server info: def get_http_info(self) -> dict[str, Any]: - info = ServerCore.get_http_info(self) + info = super().get_http_info() info["clients"] = len(self._server_sources) return info - def get_http_scripts(self) -> dict[str, Callable[[str], HttpResponse]]: - scripts = {} - for c in SERVER_BASES: - scripts.update(c.get_http_scripts(self)) - httplog("scripts=%s", scripts) - return scripts - ###################################################################### # client connections: def init_sockets(self, sockets) -> None: diff --git a/xpra/server/core.py b/xpra/server/core.py index 5649143a03..4e9b8744d0 100644 --- a/xpra/server/core.py +++ b/xpra/server/core.py @@ -12,7 +12,6 @@ import signal import platform import threading -from urllib.parse import urlparse, parse_qsl, unquote from weakref import WeakKeyDictionary from time import sleep, time, monotonic from threading import Lock @@ -54,7 +53,7 @@ from xpra.net.digest import get_salt, gendigest, choose_digest from xpra.platform import set_name, threaded_server_init from xpra.platform.info import get_username -from xpra.platform.paths import get_app_dir, get_system_conf_dirs, get_user_conf_dirs, get_icon_filename +from xpra.platform.paths import get_app_dir, get_system_conf_dirs, get_user_conf_dirs from xpra.platform.events import add_handler, remove_handler from xpra.platform.dotxpra import DotXpra from xpra.os_util import force_quit, get_machine_id, get_user_uuid, get_hex_uuid, getuid, gi_import, POSIX @@ -137,14 +136,6 @@ def proto_crypto_caps(proto) -> dict[str, Any]: return {} -def _filter_display_dict(display_dict, *whitelist): - displays_info = {} - for display, info in display_dict.items(): - displays_info[display] = {k: v for k, v in info.items() if k in whitelist} - httplog("_filter_display_dict(%s)=%s", display_dict, displays_info) - return displays_info - - def force_close_connection(conn) -> None: try: conn.close() @@ -152,11 +143,6 @@ def force_close_connection(conn) -> None: log("close_connection()", exc_info=True) -def invalid_path(uri: str) -> HttpResponse: - httplog(f"invalid request path {uri!r}") - return 404, {}, b"" - - # noinspection PyMethodMayBeStatic class ServerCore(ControlHandler): """ @@ -191,7 +177,6 @@ def __init__(self): self.ssh_upgrade = False self.rdp_upgrade = False self._html: bool = False - self._http_scripts: dict[str, Callable[[str], HttpResponse]] = {} self._www_dir: str = "" self._http_headers_dirs: list[str] = [] self.socket_info: dict[Any, dict] = {} @@ -284,7 +269,6 @@ def init(self, opts) -> None: from xpra.server.menu_provider import get_menu_provider self.menu_provider = get_menu_provider() self.init_html_proxy(opts) - self.init_http_scripts(opts.http_scripts) self.init_auth(opts) self.init_ssl(opts) self.dotxpra = DotXpra(opts.socket_dir, opts.socket_dirs + opts.client_socket_dirs) @@ -626,35 +610,6 @@ def open_url() -> None: self._http_headers_dirs.append(os.path.abspath(os.path.join(self._www_dir, "../http-headers"))) self._html = True - def init_http_scripts(self, http_scripts: str): - if http_scripts.lower() not in FALSE_OPTIONS: - script_options: dict[str, Callable[[str], HttpResponse]] = { - "/Status": self.http_status_request, - "/Info": self.http_info_request, - "/Sessions": self.http_sessions_request, - "/Displays": self.http_displays_request, - } - if self.menu_provider: - # we have menu data we can expose: - script_options |= { - "/Menu": self.http_menu_request, - "/MenuIcon": self.http_menu_icon_request, - "/DesktopMenu": self.http_desktop_menu_request, - "/DesktopMenuIcon": self.http_desktop_menu_icon_request, - } - if http_scripts.lower() in ("all", "*"): - self._http_scripts = script_options - else: - for script in http_scripts.split(","): - if not script.startswith("/"): - script = "/" + script - handler = script_options.get(script) - if not handler: - httplog.warn(f"Warning: unknown script {script!r}") - else: - self._http_scripts[script] = handler - httplog("init_http_scripts(%s)=%s", http_scripts, self._http_scripts) - ###################################################################### # authentication: def init_auth(self, opts) -> None: @@ -1552,118 +1507,8 @@ def new_websocket_client(wsh) -> None: force_close_connection(conn) def get_http_scripts(self) -> dict[str, Callable[[str], HttpResponse]]: - return self._http_scripts - - def http_query_dict(self, path) -> dict: - return dict(parse_qsl(urlparse(path).query)) - - def send_json_response(self, data) -> HttpResponse: - import json # pylint: disable=import-outside-toplevel - return self.http_response(json.dumps(data), "application/json") - - def send_icon(self, icon_type: str, icon_data: bytes) -> HttpResponse: - httplog("send_icon%s", (icon_type, Ellipsizer(icon_data))) - if not icon_data: - icon_filename = get_icon_filename("noicon.png") - icon_data = load_binary_file(icon_filename) - icon_type = "png" - httplog("using fallback transparent icon") - if icon_type == "svg" and icon_data: - from xpra.codecs.icon_util import svg_to_png # pylint: disable=import-outside-toplevel - # call svg_to_png via the main thread, - # and wait for it to complete via an Event: - icon: list[tuple[bytes, str]] = [(icon_data, icon_type)] - event = threading.Event() - - def convert() -> None: - icon[0] = svg_to_png("", icon_data, 48, 48), "png" - event.set() - - GLib.idle_add(convert) - event.wait() - icon_data, icon_type = icon[0] - if icon_type in ("png", "jpeg", "svg", "webp"): - mime_type = "image/" + icon_type - else: - mime_type = "application/octet-stream" - return self.http_response(icon_data, mime_type) - - def http_menu_request(self, _uri: str) -> HttpResponse: - xdg_menu = self.menu_provider.get_menu_data(remove_icons=True) - return self.send_json_response(xdg_menu or "not available") - - def http_desktop_menu_request(self, _uri: str) -> HttpResponse: - xsessions = self.menu_provider.get_desktop_sessions(remove_icons=True) - return self.send_json_response(xsessions or "not available") - - def http_menu_icon_request(self, uri: str) -> HttpResponse: - parts = unquote(uri).split("/MenuIcon/", 1) - # ie: "/menu-icon/a/b" -> ['', 'a/b'] - if len(parts) < 2: - return invalid_path(uri) - path = parts[1].split("/") - # ie: "a/b" -> ['a', 'b'] - category_name = path[0] - if len(path) < 2: - # only the category is present - app_name = "" - else: - app_name = path[1] - httplog("http_menu_icon_request: category_name=%s, app_name=%s", category_name, app_name) - icon_type, icon_data = self.menu_provider.get_menu_icon(category_name, app_name) - return self.send_icon(icon_type, icon_data) - - def http_desktop_menu_icon_request(self, uri: str): - parts = unquote(uri).split("/DesktopMenuIcon/", 1) - # ie: "/menu-icon/wmname" -> ['', 'sessionname'] - if len(parts) < 2: - return invalid_path(uri) - # in case the sessionname is followed by a slash: - sessionname = parts[1].split("/")[0] - httplog(f"http_desktop_menu_icon_request: {sessionname=}") - icon_type, icon_data = self.menu_provider.get_desktop_menu_icon(sessionname) - return self.send_icon(icon_type, icon_data) - - def http_displays_request(self, _uri: str): - displays = self.get_displays() - displays_info = _filter_display_dict(displays, "state", "wmname", "xpra-server-mode") - return self.send_json_response(displays_info) - - def get_displays(self) -> dict[str, Any]: - from xpra.scripts.main import get_displays_info # pylint: disable=import-outside-toplevel - return get_displays_info(self.dotxpra) - - def http_sessions_request(self, _uri): - sessions = self.get_xpra_sessions() - sessions_info = _filter_display_dict(sessions, "state", "username", "session-type", "session-name", "uuid") - return self.send_json_response(sessions_info) - - def get_xpra_sessions(self) -> dict[str, Any]: - from xpra.scripts.main import get_xpra_sessions # pylint: disable=import-outside-toplevel - return get_xpra_sessions(self.dotxpra) - - def http_info_request(self, _uri: str): - return self.send_json_response(self.get_http_info()) - - def get_http_info(self) -> dict[str, Any]: - return { - "mode": self.get_server_mode(), - "type": "Python", - "uuid": self.uuid, - } - - def http_status_request(self, _uri: str) -> HttpResponse: - return self.http_response("ready") - - def http_response(self, content, content_type: str = "text/plain") -> HttpResponse: - if not content: - return 404, {}, b"" - if isinstance(content, str): - content = content.encode("latin1") - return 200, { - "Content-type": content_type, - "Content-Length": len(content), - }, content + # loose coupling with xpra.server.mixins.http: + return getattr(self, "_http_scripts", {}) def is_timedout(self, protocol: SocketProtocol) -> bool: # subclasses may override this method (ServerBase does) diff --git a/xpra/server/features.py b/xpra/server/features.py index 65e3cda390..d10ecdf972 100644 --- a/xpra/server/features.py +++ b/xpra/server/features.py @@ -25,3 +25,4 @@ display = True windows = True rfb = True +http = True diff --git a/xpra/server/mixins/http.py b/xpra/server/mixins/http.py new file mode 100644 index 0000000000..577cc6770e --- /dev/null +++ b/xpra/server/mixins/http.py @@ -0,0 +1,193 @@ +# This file is part of Xpra. +# Copyright (C) 2010 Antoine Martin +# Xpra is released under the terms of the GNU GPL v2, or, at your option, any +# later version. See the file COPYING for details. + +from typing import Any +from collections.abc import Callable +from urllib.parse import unquote + +from xpra.util.str_fn import Ellipsizer +from xpra.util.io import load_binary_file +from xpra.scripts.config import FALSE_OPTIONS +from xpra.net.common import HttpResponse +from xpra.platform.paths import get_icon_filename +from xpra.server.mixins.stub_server_mixin import StubServerMixin +from xpra.log import Logger + +log = Logger("http") + + +def invalid_path(uri: str) -> HttpResponse: + log(f"invalid request path {uri!r}") + return 404, {}, b"" + + +def http_response(content, content_type: str = "text/plain") -> HttpResponse: + if not content: + return 404, {}, b"" + if isinstance(content, str): + content = content.encode("latin1") + return 200, { + "Content-type": content_type, + "Content-Length": len(content), + }, content + + +def http_status_request() -> HttpResponse: + return http_response("ready") + + +def http_icon_response(icon_type: str, icon_data: bytes) -> HttpResponse: + log("http_icon_response%s", (icon_type, Ellipsizer(icon_data))) + if not icon_data: + icon_filename = get_icon_filename("noicon.png") + icon_data = load_binary_file(icon_filename) + icon_type = "png" + log("using fallback transparent icon") + if icon_type == "svg" and icon_data: + from xpra.codecs.icon_util import svg_to_png # pylint: disable=import-outside-toplevel + # call svg_to_png via the main thread, + # and wait for it to complete via an Event: + icon: list[tuple[bytes, str]] = [(icon_data, icon_type)] + from threading import Event + event = Event() + + def convert() -> None: + icon[0] = svg_to_png("", icon_data, 48, 48), "png" + event.set() + + from xpra.os_util import gi_import + GLib = gi_import("GLib") + GLib.idle_add(convert) + event.wait() + icon_data, icon_type = icon[0] + if icon_type in ("png", "jpeg", "svg", "webp"): + mime_type = "image/" + icon_type + else: + mime_type = "application/octet-stream" + return http_response(icon_data, mime_type) + + +def send_json_response(data) -> HttpResponse: + import json # pylint: disable=import-outside-toplevel + return http_response(json.dumps(data), "application/json") + + +def _filter_display_dict(display_dict: dict[str, Any], *whitelist: str) -> dict[str, Any]: + displays_info = {} + for display, info in display_dict.items(): + displays_info[display] = {k: v for k, v in info.items() if k in whitelist} + log("_filter_display_dict(%s)=%s", display_dict, displays_info) + return displays_info + + +class HttpServer(StubServerMixin): + """ + Mixin for servers that can handle http requests + """ + + def __init__(self): + self._http_scripts = {} + + def init(self, opts) -> None: + http_scripts = opts.http_scripts + if http_scripts.lower() in FALSE_OPTIONS: + return + script_options: dict[str, Callable[[str], HttpResponse]] = { + "/Status": http_status_request, + "/Info": self.http_info_request, + "/Sessions": self.http_sessions_request, + "/Displays": self.http_displays_request, + } + if self.menu_provider: + # we have menu data we can expose: + script_options |= { + "/Menu": self.http_menu_request, + "/MenuIcon": self.http_menu_icon_request, + "/DesktopMenu": self.http_desktop_menu_request, + "/DesktopMenuIcon": self.http_desktop_menu_icon_request, + } + if http_scripts.lower() in ("all", "*"): + self._http_scripts = script_options + else: + for script in http_scripts.split(","): + if not script.startswith("/"): + script = "/" + script + handler = script_options.get(script) + if not handler: + log.warn(f"Warning: unknown script {script!r}") + else: + self._http_scripts[script] = handler + log("init_http_scripts(%s)=%s", http_scripts, self._http_scripts) + + def cleanup(self) -> None: + self._http_scripts = {} + + def get_info(self, _proto=None) -> dict[str, Any]: + return { + } + + def http_menu_request(self, _uri: str) -> HttpResponse: + xdg_menu = self.menu_provider.get_menu_data(remove_icons=True) + return send_json_response(xdg_menu or "not available") + + def http_desktop_menu_request(self, _uri: str) -> HttpResponse: + xsessions = self.menu_provider.get_desktop_sessions(remove_icons=True) + return send_json_response(xsessions or "not available") + + def http_menu_icon_request(self, uri: str) -> HttpResponse: + parts = unquote(uri).split("/MenuIcon/", 1) + # ie: "/menu-icon/a/b" -> ['', 'a/b'] + if len(parts) < 2: + return invalid_path(uri) + path = parts[1].split("/") + # ie: "a/b" -> ['a', 'b'] + category_name = path[0] + if len(path) < 2: + # only the category is present + app_name = "" + else: + app_name = path[1] + log("http_menu_icon_request: category_name=%s, app_name=%s", category_name, app_name) + icon_type, icon_data = self.menu_provider.get_menu_icon(category_name, app_name) + return http_icon_response(icon_type, icon_data) + + def http_desktop_menu_icon_request(self, uri: str) -> HttpResponse: + parts = unquote(uri).split("/DesktopMenuIcon/", 1) + # ie: "/menu-icon/wmname" -> ['', 'sessionname'] + if len(parts) < 2: + return invalid_path(uri) + # in case the sessionname is followed by a slash: + sessionname = parts[1].split("/")[0] + log(f"http_desktop_menu_icon_request: {sessionname=}") + icon_type, icon_data = self.menu_provider.get_desktop_menu_icon(sessionname) + return http_icon_response(icon_type, icon_data) + + def http_displays_request(self, _uri: str) -> HttpResponse: + displays = self.get_displays() + displays_info = _filter_display_dict(displays, "state", "wmname", "xpra-server-mode") + return send_json_response(displays_info) + + def get_displays(self) -> dict[str, Any]: + from xpra.scripts.main import get_displays_info # pylint: disable=import-outside-toplevel + return get_displays_info(self.dotxpra) + + def http_sessions_request(self, _uri) -> HttpResponse: + sessions = self.get_xpra_sessions() + sessions_info = _filter_display_dict(sessions, "state", "username", "session-type", "session-name", "uuid") + return send_json_response(sessions_info) + + def get_xpra_sessions(self) -> dict[str, Any]: + from xpra.scripts.main import get_xpra_sessions # pylint: disable=import-outside-toplevel + return get_xpra_sessions(self.dotxpra) + + def http_info_request(self, _uri: str) -> HttpResponse: + return send_json_response(self.get_http_info()) + + def get_http_info(self) -> dict[str, Any]: + return { + "mode": self.get_server_mode(), + "type": "Python", + "uuid": self.uuid, + } diff --git a/xpra/server/mixins/stub_server_mixin.py b/xpra/server/mixins/stub_server_mixin.py index 0db39b0a10..ccccb5dc58 100644 --- a/xpra/server/mixins/stub_server_mixin.py +++ b/xpra/server/mixins/stub_server_mixin.py @@ -10,7 +10,7 @@ from xpra.util.objects import typedict from xpra.os_util import WIN32 -from xpra.net.common import ServerPacketHandlerType, HttpResponse +from xpra.net.common import ServerPacketHandlerType class StubServerMixin: @@ -148,9 +148,6 @@ def get_full_child_command(self, cmd, _use_wrapper: bool = True) -> list[str]: return [cmd] return shlex.split(str(cmd)) - def get_http_scripts(self) -> dict[str, Callable[[str], HttpResponse]]: - return {} - def add_packet_handler(self, packet_type: str, handler: ServerPacketHandlerType, main_thread=True) -> None: """ register a packet handler """ diff --git a/xpra/server/proxy/server.py b/xpra/server/proxy/server.py index a172a2ea44..7d11e8234a 100644 --- a/xpra/server/proxy/server.py +++ b/xpra/server/proxy/server.py @@ -136,6 +136,9 @@ def get_proxy_server_base_classes() -> tuple[type, ...]: if features.dbus: from xpra.server.mixins.dbus import DbusServer classes.append(DbusServer) + if features.http: + from xpra.server.mixins.http import HttpServer + classes.append(HttpServer) return tuple(classes) @@ -153,7 +156,8 @@ class ProxyServer(ProxyServerBaseClass): def __init__(self): log("ProxyServer.__init__()") - super().__init__() + for bc in SERVER_BASES: + bc.__init__(self) self._max_connections = MAX_CONCURRENT_CONNECTIONS self._start_sessions = False self.session_type = "proxy" @@ -178,7 +182,8 @@ def init(self, opts) -> None: self.pings = int(opts.pings) self.video_encoders = opts.proxy_video_encoders self._start_sessions = opts.proxy_start_sessions - super().init(opts) + for bc in SERVER_BASES: + bc.init(self, opts) # ensure we cache the platform info before intercepting SIGCHLD # as this will cause a fork and SIGCHLD to be emitted: from xpra.util.version import get_platform_info @@ -268,7 +273,8 @@ def stop_proxy(self, instance, force: bool = False) -> None: def cleanup(self) -> None: self.stop_all_proxies() - super().cleanup() + for bc in SERVER_BASES: + bc.cleanup(self) start = monotonic() live = True log("cleanup() proxy instances: %s", self.instances)