From 49b4ea216ac1d2adf5e121c3069e4749ef18ed4b Mon Sep 17 00:00:00 2001 From: Benjamin Braatz Date: Mon, 22 Mar 2021 22:10:53 +0100 Subject: [PATCH] Allow web apps in package directories. --- .gitignore | 1 + MANIFEST.in | 2 + conf.json | 6 +- .../web/Debug}/controlpi-debug.css | 0 .../web/Debug}/controlpi-debug.js | 0 controlpi_plugins/web/Debug/index.css | 71 ++++++++++++++ controlpi_plugins/web/Debug/index.html | 31 ++++++ controlpi_plugins/wsserver.py | 98 +++++++++++++------ setup.py | 1 + web/index.css | 16 ++- web/index.html | 7 +- 11 files changed, 192 insertions(+), 41 deletions(-) create mode 100644 MANIFEST.in rename {web => controlpi_plugins/web/Debug}/controlpi-debug.css (100%) rename {web => controlpi_plugins/web/Debug}/controlpi-debug.js (100%) create mode 100644 controlpi_plugins/web/Debug/index.css create mode 100644 controlpi_plugins/web/Debug/index.html diff --git a/.gitignore b/.gitignore index aa9a585..af06ced 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ __pycache__/ +dist/ controlpi_wsserver.egg-info/ venv/ diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..6f2f309 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +recursive-include doc/ * +recursive-include controlpi_plugins/web/ * diff --git a/conf.json b/conf.json index bb7087a..f24a053 100644 --- a/conf.json +++ b/conf.json @@ -2,7 +2,11 @@ "Example Server": { "plugin": "WSServer", "port": 8080, - "web root": "web" + "web": { + "/": {"location": "web"}, + "/Debug": {"module": "controlpi_plugins.wsserver", + "location": "Debug"} + } }, "Example State": { "plugin": "State" diff --git a/web/controlpi-debug.css b/controlpi_plugins/web/Debug/controlpi-debug.css similarity index 100% rename from web/controlpi-debug.css rename to controlpi_plugins/web/Debug/controlpi-debug.css diff --git a/web/controlpi-debug.js b/controlpi_plugins/web/Debug/controlpi-debug.js similarity index 100% rename from web/controlpi-debug.js rename to controlpi_plugins/web/Debug/controlpi-debug.js diff --git a/controlpi_plugins/web/Debug/index.css b/controlpi_plugins/web/Debug/index.css new file mode 100644 index 0000000..e5d2c50 --- /dev/null +++ b/controlpi_plugins/web/Debug/index.css @@ -0,0 +1,71 @@ +@import url('https://fonts.googleapis.com/css?family=Roboto'); +@import url('https://fonts.googleapis.com/css?family=Michroma'); + +* { + vertical-align: baseline; + margin: 0; + outline: 0; + border: 0 none; + padding: 0; + font-family: 'Roboto', sans-serif; + font-weight: inherit; + font-style: inherit; + font-size: inherit; +} + +html { + font-size: 16px; +} + +body { + background-color: white; + color: black; +} + +body > header { + padding: 10px; + background-color: #2c343b; +} + +body > header * { + vertical-align: middle; +} + +body > header svg { + height: 40px; +} + +body > header span { + font-family: "Michroma", sans-serif; + font-weight: bold; + font-size: 20px; + text-transform: uppercase; +} + +body > header span.Graph { + margin-left: 10px; + color: white; +} + +body > header span.IT { + margin-right: 10px; + color: #83a1b4; +} + +h1, h2, h3 { + margin-top: 5px; + margin-left: 5px; + font-weight: bold; +} + +h1 { + font-size: 1.4rem; +} + +h2 { + font-size: 1.2rem; +} + +h3 { + font-size: 1.2rem; +} diff --git a/controlpi_plugins/web/Debug/index.html b/controlpi_plugins/web/Debug/index.html new file mode 100644 index 0000000..f6d6ff5 --- /dev/null +++ b/controlpi_plugins/web/Debug/index.html @@ -0,0 +1,31 @@ + + + + + + ControlPi Debug + + + + + + +
+ + + + + + + + + + + + Graph-IT +
+

ControlPi Debug

+
+
+ + diff --git a/controlpi_plugins/wsserver.py b/controlpi_plugins/wsserver.py index 2a8d625..fc7a3fe 100644 --- a/controlpi_plugins/wsserver.py +++ b/controlpi_plugins/wsserver.py @@ -3,20 +3,22 @@ … TODO: documentation, doctests -TODO: Mount multiple web apps from packages and file paths TODO: Let Debug web app collapse/expand nested structures TODO: Make Debug web app work with nested structures in commands +TODO: Let clients filter messages received over websocket """ import os +import sys import json from websockets import (WebSocketServerProtocol, ConnectionClosedOK, ConnectionClosedError, serve) from websockets.http import Headers from http import HTTPStatus -from typing import Optional, Tuple from controlpi import BasePlugin, MessageBus, Message, MessageTemplate +from typing import Optional, Tuple + class Connection: """Connection to websocket. @@ -78,9 +80,17 @@ class WSServer(BasePlugin): the contents in given web root. """ - CONF_SCHEMA = {'properties': {'host': {'type': 'string'}, - 'port': {'type': 'integer'}, - 'web root': {'type': 'string'}}} + CONF_SCHEMA = {'properties': + {'host': {'type': 'string'}, + 'port': {'type': 'integer'}, + 'web': {'type': 'object', + 'patternProperties': + {'^/([A-Z][A-Za-z]*)?$': + {'type': 'object', + 'properties': {'module': {'type': 'string'}, + 'location': {'type': 'string'}}, + 'required': ['location']}}, + 'additionalProperties': False}}} """Schema for WServer plugin configuration. Optional configuration keys: @@ -88,7 +98,9 @@ class WSServer(BasePlugin): - 'host': network interfaces to listen on (default: None, meaning all interfaces) - 'port': port to connect to (default: 80) - - 'web root': root of files to serve (default: 'web') + - 'web': mapping of web paths to locations on disk (either as absolute + path, relative path from the working directory or path from the + path containing a given module) """ async def _handler(self, websocket: WebSocketServerProtocol, @@ -100,49 +112,71 @@ class WSServer(BasePlugin): request_headers: Headers) -> Response: if 'Upgrade' in request_headers: return None - if path == '/': - path = '/index.html' response_headers = Headers() response_headers['Server'] = 'controlpi-wsserver websocket server' response_headers['Connection'] = 'close' - file_path = os.path.realpath(os.path.join(self._web_root, path[1:])) - if os.path.commonpath((self._web_root, file_path)) != self._web_root \ - or not os.path.exists(file_path) \ - or not os.path.isfile(file_path): + if path not in self._web_files: return (HTTPStatus.NOT_FOUND, response_headers, f"File '{path}' not found!".encode()) - mime_type = 'application/octet-stream' - extension = file_path.split('.')[-1] - if extension == 'html': - mime_type = 'text/html' - elif extension == 'js': - mime_type = 'text/javascript' - elif extension == 'css': - mime_type = 'text/css' - response_headers['Content-Type'] = mime_type - body = open(file_path, 'rb').read() + file_info = self._web_files[path] + response_headers['Content-Type'] = file_info['type'] + body = open(file_info['location'], 'rb').read() response_headers['Content-Length'] = str(len(body)) return HTTPStatus.OK, response_headers, body def process_conf(self) -> None: """Get host, port and path settings from configuration.""" + self._host = None + if 'host' in self.conf: + self._host = self.conf['host'] + else: + print(f"'host' not configured for WSServer '{self.name}'." + " Serving on all interfaces.") self._port = 80 if 'port' in self.conf: self._port = self.conf['port'] else: print(f"'port' not configured for WSServer '{self.name}'." - " Using 80.") - web_root = 'web' - if 'web root' in self.conf: - web_root = self.conf['web root'] - else: - print(f"'web root' not configured for WSServer '{self.name}'." - " Using 'web'.") - self._web_root = os.path.realpath(os.path.join(os.getcwd(), - web_root)) + " Using port 80.") + self._web_files = {} + if 'web' in self.conf: + for path in self.conf['web']: + path_conf = self.conf['web'][path] + location = path_conf['location'] + if 'module' in path_conf: + # Determine location relative to module directory: + module_file = sys.modules[path_conf['module']].__file__ + module_dir = os.path.dirname(module_file) + location = os.path.join(module_dir, 'web', location) + else: + # Determine location relative to current working directory: + location = os.path.join(os.getcwd(), location) + location = os.path.realpath(location) + # Walk all files in location recursively: + for (dir_location, _, filenames) in os.walk(location): + dir_path = os.path.join(path, dir_location[len(location):]) + for filename in filenames: + file_path = os.path.join(dir_path, filename) + file_location = os.path.join(dir_location, filename) + file_info = {'location': file_location} + # Determine MIME type: + file_info['type'] = 'application/octet-stream' + extension = filename.split('.')[-1] + if extension == 'html': + file_info['type'] = 'text/html' + elif extension == 'js': + file_info['type'] = 'text/javascript' + elif extension == 'css': + file_info['type'] = 'text/css' + # Register in instance variable: + self._web_files[file_path] = file_info + # Serve index.html also as directory: + if filename == 'index.html': + self._web_files[dir_path] = file_info + self._web_files[dir_path.rstrip('/')] = file_info async def run(self) -> None: """Set up websocket server.""" - await serve(self._handler, port=self._port, + await serve(self._handler, host=self._host, port=self._port, process_request=self._process_request) print(f"WSServer '{self.name}' serving on port {self._port}.") diff --git a/setup.py b/setup.py index 76aca3a..d7067f7 100644 --- a/setup.py +++ b/setup.py @@ -13,6 +13,7 @@ setuptools.setup( long_description_content_type="text/markdown", url="http://docs.graph-it.com/graphit/controlpi-wsserver", packages=["controlpi_plugins"], + include_package_data=True, install_requires=[ "websockets", "controlpi @ git+git://git.graph-it.com/graphit/controlpi.git", diff --git a/web/index.css b/web/index.css index e0f29d1..e5d2c50 100644 --- a/web/index.css +++ b/web/index.css @@ -52,10 +52,20 @@ body > header span.IT { color: #83a1b4; } -h1 { +h1, h2, h3 { margin-top: 5px; margin-left: 5px; - margin-right: 5px; - font-size: 1.4rem; font-weight: bold; } + +h1 { + font-size: 1.4rem; +} + +h2 { + font-size: 1.2rem; +} + +h3 { + font-size: 1.2rem; +} diff --git a/web/index.html b/web/index.html index f6d6ff5..59c384e 100644 --- a/web/index.html +++ b/web/index.html @@ -6,8 +6,6 @@ ControlPi Debug - -
@@ -24,8 +22,7 @@ Graph-IT
-

ControlPi Debug

-
-
+

ControlPi

+

Debug

-- 2.34.1