From 6128e895bc2a5da5fe645cc9a7ad74ac75af4f6b Mon Sep 17 00:00:00 2001 From: mar77i Date: Mon, 1 Jan 2024 20:14:52 +0100 Subject: [PATCH] serve other hubapps too, consolidate and a lot more... --- hub/app.py | 92 +++++++++++-------- hub/hub.py | 105 --------------------- hub/hubapp.py | 150 ++++++++++++++++++++++++++++++ hub/staticresource.py | 96 -------------------- hub/utils.py | 26 +++++- hub/websocket.py | 139 ++++++++++++++++++++++++++++ hubapps/first/common.js | 125 ------------------------- hubapps/first/index.html | 18 ---- hubapps/first/index.js | 80 ---------------- hubapps/first/master.html.tpl | 18 ---- hubapps/first/master.js | 86 ------------------ requirements.txt | 32 +++---- run_server.py | 4 +- setup_venv.sh | 3 +- webroot/first/common.js | 85 +++++++++++++++++ webroot/first/index.html.j2 | 18 ++++ webroot/first/index.js | 97 ++++++++++++++++++++ webroot/first/master.html.j2 | 18 ++++ webroot/first/master.js | 166 ++++++++++++++++++++++++++++++++++ webroot/index.html.j2 | 22 +++++ webroot/style.css | 4 + 21 files changed, 797 insertions(+), 587 deletions(-) delete mode 100644 hub/hub.py create mode 100644 hub/hubapp.py delete mode 100644 hub/staticresource.py create mode 100644 hub/websocket.py delete mode 100644 hubapps/first/common.js delete mode 100644 hubapps/first/index.html delete mode 100644 hubapps/first/index.js delete mode 100644 hubapps/first/master.html.tpl delete mode 100644 hubapps/first/master.js create mode 100644 webroot/first/common.js create mode 100644 webroot/first/index.html.j2 create mode 100644 webroot/first/index.js create mode 100644 webroot/first/master.html.j2 create mode 100644 webroot/first/master.js create mode 100644 webroot/index.html.j2 create mode 100644 webroot/style.css diff --git a/hub/app.py b/hub/app.py index c5c9247..959024f 100644 --- a/hub/app.py +++ b/hub/app.py @@ -1,49 +1,55 @@ +import socket +import sys from argparse import ArgumentParser from itertools import chain +from pathlib import Path from secrets import token_urlsafe -import socket from subprocess import run -import sys from traceback import print_exception -from typing import List, Optional from urllib.parse import urlunsplit -from falcon.asgi import App +from falcon.asgi import App as FalconApp from falcon.constants import MEDIA_HTML +from jinja2 import Environment, FileSystemLoader, select_autoescape from uvicorn import Config, Server -from .staticresource import StaticResource -from .hub import Hub +from .hubapp import HubApp, RootApp -class HubApp(App): - def __init__(self, secret, browser, hubapp, *args, **kwargs): +class App(FalconApp): + def __init__(self, secret, **kwargs): kwargs.setdefault("media_type", MEDIA_HTML) - super().__init__(*args, **kwargs) + super().__init__(**kwargs) + self.base_dir = Path(__file__).parents[1] / "webroot" + self.env = Environment( + loader=FileSystemLoader(self.base_dir), + autoescape=select_autoescape, + extensions=["hub.utils.StaticTag"], + ) self.secret = secret or token_urlsafe(64) - self.hub = Hub(self.secret) - self.sr = StaticResource(self.secret, hubapp) - self.browser = browser - self.hub.add_routes(self) - self.sr.add_routes(self) - self.hub.update_context_vars(self.sr.context_vars) + self.hubapps = {} + RootApp(self, self.base_dir) + for base_dir in self.base_dir.iterdir(): + if not base_dir.is_dir() or HubApp.is_ignored_filename(base_dir): + continue + HubApp(self, base_dir) self.add_error_handler(Exception, self.print_exception) + def get_hubapp_by_name(self, name): + if name == "root": + name = "" + return self.hubapps[name] + async def print_exception(self, req, resp, ex, params): print_exception(*sys.exc_info()) -def get_app(): - ap = ArgumentParser() - ap.add_argument("--secret") - ap.add_argument("--browser", default="firefox") - ap.add_argument("--hubapp", default="first") - args = ap.parse_args() - return HubApp(args.secret, args.browser, args.hubapp) - - class HubServer(Server): - async def startup(self, sockets: Optional[List[socket.socket]] = None) -> None: + def __init__(self, *args, browser, **kwargs): + self.browser = browser + super().__init__(*args, **kwargs) + + async def startup(self, sockets: list[socket.socket] | None = None) -> None: await super().startup(sockets) config = self.config protocol_name = "https" if config.ssl else "http" @@ -56,23 +62,35 @@ class HubServer(Server): if port == 0: try: port = next( - chain.from_iterable((server.sockets for server in getattr(self, "servers", ()))) + chain.from_iterable( + (server.sockets for server in getattr(self, "servers", ())) + ) ).getsockname()[1] except StopIteration: pass if {"http": 80, "https": 443}[protocol_name] != port: host = f"{host}:{port}" app = config.loaded_app.app - print("master_uri", app.sr.master_uri) - master_url = urlunsplit((protocol_name, host, app.sr.master_uri, "", "")) - print("secret:", app.secret) - if not app.browser: - print("master url", master_url) - else: - run([app.browser, master_url]) + print("Secret:", app.secret) + for key, value in app.root_app.files_per_uris.items(): + if Path(value.path.name).stem == "index.html": + url = urlunsplit((protocol_name, host, key, "", "")) + print("URL:", url) + if self.browser: + run([self.browser, url]) + +app: App -def main(): - HubServer( - Config("hub.app:get_app", factory=True, port=5000, log_level="info") - ).run() + +async def main(): + global app + ap = ArgumentParser() + ap.add_argument("--secret") + ap.add_argument("--browser", default="firefox") + args = ap.parse_args() + app = App(args.secret) + config = Config("hub.app:app", port=5000, log_level="info") + config.setup_event_loop() + hs = HubServer(config, browser=args.browser) + await hs.serve() diff --git a/hub/hub.py b/hub/hub.py deleted file mode 100644 index da78e1f..0000000 --- a/hub/hub.py +++ /dev/null @@ -1,105 +0,0 @@ -import asyncio -from asyncio.exceptions import CancelledError -import json -import pickle -import sys -from traceback import print_exception - -from falcon import WebSocketDisconnected -from redis.asyncio import StrictRedis - -from .utils import get_redis_pass, scramble - - -class Hub: - def __init__(self, secret): - self.master_ws_uri = f"/{scramble(secret, 'ws')}" - self.redis = StrictRedis(password=get_redis_pass("/etc/redis/redis.conf")) - asyncio.ensure_future(self.redis.set("client_id", 0)) - - async def process_websocket(self, client_id, web_socket): - try: - while True: - data = await web_socket.receive_text() - try: - parsed_data = json.loads(data) - except json.JSONDecodeError: - parsed_data = None - if not isinstance(parsed_data, dict): - parsed_data = {"data": data} - parsed_data["client_id"] = client_id - await self.redis.publish("master", pickle.dumps(parsed_data)) - except (CancelledError, WebSocketDisconnected): - pass - - async def process_pubsub(self, pubsub, web_socket): - try: - while True: - data = await pubsub.get_message(True, .3) - if not web_socket.ready or web_socket.closed: - break - if data is not None: - await web_socket.send_text(json.dumps(pickle.loads(data["data"]))) - except (CancelledError, WebSocketDisconnected): - pass - - async def on_websocket(self, req, web_socket): - client_id = await self.redis.incr("client_id") - await web_socket.accept() - pubsub = self.redis.pubsub() - await pubsub.subscribe(f"client-{client_id}") - await self.redis.publish( - "master", pickle.dumps({"action": "join", "client_id": client_id}), - ) - try: - await asyncio.gather( - self.process_websocket(client_id, web_socket), - self.process_pubsub(pubsub, web_socket), - return_exceptions=True, - ) - except (CancelledError, WebSocketDisconnected): - pass - except Exception: - print_exception(*sys.exc_info()) - finally: - await web_socket.close() - await self.redis.publish( - "master", - pickle.dumps({"action": "leave", "client_id": client_id}), - ) - - async def process_websocket_master(self, web_socket): - try: - while True: - data = json.loads(await web_socket.receive_text()) - for client_id in data.pop("client_ids", ()): - await self.redis.publish( - f"client-{client_id}", - pickle.dumps(data), - ) - except (CancelledError, WebSocketDisconnected) as e: - pass - - async def on_websocket_master(self, req, web_socket): - await web_socket.accept() - pubsub = self.redis.pubsub() - await pubsub.subscribe("master") - try: - await asyncio.gather( - self.process_websocket_master(web_socket), - self.process_pubsub(pubsub, web_socket), - return_exceptions=True, - ) - except (CancelledError, WebSocketDisconnected): - pass - except Exception: - print_exception(*sys.exc_info()) - finally: - await web_socket.close() - - def add_routes(self, app): - app.add_route("/ws", self) - app.add_route(self.master_ws_uri, self, suffix="master") - - def update_context_vars(self, context_vars): - context_vars["master_ws_uri"] = self.master_ws_uri diff --git a/hub/hubapp.py b/hub/hubapp.py new file mode 100644 index 0000000..bd2540b --- /dev/null +++ b/hub/hubapp.py @@ -0,0 +1,150 @@ +from pathlib import Path + +from falcon.constants import MEDIA_HTML, MEDIA_JS, MEDIA_TEXT +from falcon.status_codes import HTTP_OK + +from .websocket import WebSocketHub +from .utils import scramble + +MEDIA_CSS = "text/css" + + +class StaticFile: + media_types_per_suffix = { + ".html": MEDIA_HTML, + ".js": MEDIA_JS, + ".css": MEDIA_CSS, + ".txt": MEDIA_TEXT, + } + + def __init__(self, path): + self.path = path + self.media_type = self.get_media_type(path) + self.mtime = None + self.content = None + + @classmethod + def get_media_type(cls, path): + return cls.media_types_per_suffix[path.suffix] + + def get(self): + mtime = self.path.stat().st_mtime + if mtime != self.mtime: + with open(self.path) as fh: + self.content = fh.read() + self.mtime = mtime + return self.content + + def __repr__(self): + return f"<{type(self).__name__} {self.path}>" + + +class StaticTemplateFile(StaticFile): + TEMPLATE_SUFFIX = ".j2" + + def __init__(self, path, hubapp): + super().__init__(path) + self.hubapp = hubapp + self.context = {"hubapp": self.hubapp} + self.template = hubapp.app.env.get_template( + str(path.relative_to(hubapp.app.env.loader.searchpath[0])) + ) + + def get(self): + return self.template.render(self.context) + + @classmethod + def get_media_type(cls, path): + assert path.suffix == cls.TEMPLATE_SUFFIX + return cls.media_types_per_suffix[path.suffixes[-2]] + + +class BaseHubApp: + SCAN_FILES_RECURSIVELY = True + + def __init__(self, app, base_dir, name=None): + self.app = app + self.base_dir = base_dir + self.name = name if name is not None else base_dir.name + self.app.hubapps[self.name] = self + self.files_per_uris = { + self.uri_from(file.path): file for file in self.scan_files(base_dir) + } + for uri in self.files_per_uris: + app.add_route(uri, self) + + @staticmethod + def is_master_uri(uri_tail): + slash = "/" + start = uri_tail.rfind(slash) + pos = uri_tail.find(".", start if start >= 0 else 0) + return ( + uri_tail[start + len(slash):pos] == "master" + or "master" in uri_tail[:start].split(slash) + ) + + def uri_from(self, path): + if isinstance(path, Path) and path.is_absolute(): + uri_tail = str(path.relative_to(self.base_dir)) + else: + uri_tail = str(path) + suffix = StaticTemplateFile.TEMPLATE_SUFFIX + if uri_tail.endswith(suffix): + uri_tail = uri_tail[:-len(suffix)] + + if self.is_master_uri(uri_tail): + uri_tail = scramble(self.app.secret, uri_tail) + elif uri_tail == "index.html": + uri_tail = "" + name = self.name + if name and uri_tail: + name = f"{name}/" + return f"/{name}{uri_tail}" + + def scan_files(self, base_dir): + stack = [base_dir.iterdir()] + while len(stack): + try: + path = next(stack[-1]) + except StopIteration: + stack.pop() + continue + if path.is_dir(): + if self.SCAN_FILES_RECURSIVELY: + stack.append(path.iterdir()) + elif not self.is_ignored_filename(path): + if path.suffix == StaticTemplateFile.TEMPLATE_SUFFIX: + static_file = StaticTemplateFile(path, self) + else: + static_file = StaticFile(path) + yield static_file + + @staticmethod + def is_ignored_filename(path): + return path.name.startswith(".") + + async def on_get(self, req, resp): + resource = self.files_per_uris[req.path] + resp.content_type = resource.media_type + resp.data = resource.get().encode() + resp.status = HTTP_OK + + +class HubApp(BaseHubApp): + def __init__(self, app, base_dir): + super().__init__(app, base_dir) + self.wsh = WebSocketHub(self) + self.ws_uris = {self.uri_from(f"ws_{value}"): value for value in ("client", "master")} + for uri, suffix in self.ws_uris.items(): + app.add_route(uri, self.wsh, suffix=suffix) + + +class RootApp(BaseHubApp): + SCAN_FILES_RECURSIVELY = False + + def __init__(self, app, base_dir): + super().__init__(app, base_dir, "") + + @staticmethod + def is_master_uri(uri_tail): + return True diff --git a/hub/staticresource.py b/hub/staticresource.py deleted file mode 100644 index e1e86dd..0000000 --- a/hub/staticresource.py +++ /dev/null @@ -1,96 +0,0 @@ -from io import BytesIO -import json -import mimetypes -import os -from pathlib import Path - -from .utils import scramble - - -class StaticFile: - template_ext = ".tpl" - template_filters = { - b"tojson": lambda value: json.dumps(value) - } - - def __init__(self, path): - self.path = path - self.mime_type = mimetypes.guess_type(self.remove_template_ext(str(path)))[0] - self.mtime = None - self.content = None - - def load_content(self): - with open(self.path, "rb") as fh: - return fh.read() - - def get(self, context_vars): - content = self.load_content() if self.path.stat().st_mtime != self.mtime else self.content - if self.path.suffix == self.template_ext: - content = self.render(content, context_vars) - self.content = content - return content - - def render(self, content, context_vars): - bio = BytesIO() - pos = 0 - while True: - new_pos = content.find(b"{{", pos) - if new_pos < 0: - break - bio.write(content[pos:new_pos]) - end_pos = content.find(b"}}", new_pos) - assert end_pos > 0 - full_tag = content[new_pos + 2:end_pos].split(b"|", 1) - value = context_vars[full_tag[0].strip().decode()] - if len(full_tag) == 2: - value = self.template_filters[full_tag[1].strip()](value) - bio.write(value.encode()) - pos = end_pos + 2 - bio.write(content[pos:]) - return bio.getvalue() - - def __str__(self): - return f"" - - @classmethod - def remove_template_ext(cls, filename): - if filename.endswith(cls.template_ext): - filename = filename[:-4] - return filename - - -class StaticResource: - def __init__(self, secret, current_hubapp): - mimetypes.init() - self.static_files = {} - self.context_vars = {} - self.master_uri = "" - self.setup(secret, current_hubapp) - - def setup(self, secret, current_hubapp): - hubapp_dir = Path(__file__).parents[1] / "hubapps" / current_hubapp - for file in os.listdir(hubapp_dir): - uri_file = StaticFile.remove_template_ext(file) - if uri_file.startswith("master."): - uri = f"/{scramble(secret, uri_file)}" - if uri_file == "master.html": - self.master_uri = uri - self.context_vars[uri_file.replace(".", "_")] = uri - else: - uri = f"/{uri_file}" - self.static_files[uri] = StaticFile(hubapp_dir / file) - if uri_file == "index.html": - self.static_files["/"] = self.static_files[uri] - - async def on_get(self, req, resp): - path = req.path - index_uri = f"/index.html" - if path == "/": - path = index_uri - res = self.static_files[path] - resp.data = res.get(self.context_vars) - resp.content_type = res.mime_type - - def add_routes(self, app): - for uri_template in self.static_files: - app.add_route(uri_template, self) diff --git a/hub/utils.py b/hub/utils.py index c9d955c..ae1fd12 100644 --- a/hub/utils.py +++ b/hub/utils.py @@ -1,11 +1,29 @@ from base64 import urlsafe_b64encode -from hashlib import sha3_512 +from hashlib import pbkdf2_hmac +from pathlib import Path + +from jinja2_simple_tags import StandaloneTag + + +class StaticTag(StandaloneTag): + tags = {"static"} + + def render(self, filename="/", hubapp=None): + if not hubapp: + hubapp = self.context["hubapp"] + elif isinstance(hubapp, str): + hubapp = self.context["hubapp"].app.get_hubapp_by_name(hubapp) + return hubapp.uri_from(Path(filename)) def scramble(secret, value): - h = sha3_512() - h.update(f"{secret}{value}".encode()) - return urlsafe_b64encode(h.digest()).rstrip(b"=").decode("ascii") + if isinstance(value, str): + value = value.encode() + if isinstance(secret, str): + secret = secret.encode() + return urlsafe_b64encode( + pbkdf2_hmac("sha512", value, secret, 221100) + ).rstrip(b"=").decode("ascii") def get_redis_pass(redis_conf): diff --git a/hub/websocket.py b/hub/websocket.py new file mode 100644 index 0000000..3d51116 --- /dev/null +++ b/hub/websocket.py @@ -0,0 +1,139 @@ +import asyncio +import json +import pickle +import sys +from asyncio.exceptions import CancelledError +from functools import partial +from traceback import print_exception + +from falcon import WebSocketDisconnected +from redis.asyncio import StrictRedis + +from .utils import get_redis_pass + + +class BaseWebSocketHub: + first = True + client_ids_sem = asyncio.Semaphore(0) + + @classmethod + def __class_init(cls, redis): + if not cls.first: + return + cls.first = False + asyncio.create_task(cls.initialize_client_ids(redis)) + + @classmethod + async def initialize_client_ids(cls, redis): + await redis.set("client_id", 0) + cls.client_ids_sem.release() + + def __init__(self): + self.redis = StrictRedis(password=get_redis_pass("/etc/redis/redis.conf")) + self.__class_init(self.redis) + + def task_done(self): + self.task = None + + @staticmethod + async def process_websocket(redis, web_socket, extra_data={}, recipients=[]): + try: + while True: + data = json.loads(await web_socket.receive_text()) + data.update(extra_data) + if callable(recipients): + current_recipients = recipients(data) + else: + current_recipients = recipients + for recipient in current_recipients: + await redis.publish(recipient, pickle.dumps(data)) + except (CancelledError, WebSocketDisconnected): + pass + + @staticmethod + async def process_pubsub(pubsub, web_socket): + try: + while True: + data = await pubsub.get_message(True, 0.3) + if not web_socket.ready or web_socket.closed: + break + if data is not None: + await web_socket.send_text(json.dumps(pickle.loads(data["data"]))) + except (CancelledError, WebSocketDisconnected): + pass + + async def on_websocket( + self, + req, + web_socket, + pubsub_name=None, + process_websockets_kwargs=None, + join_cb=None, + leave_cb=None, + ): + await web_socket.accept() + pubsub = self.redis.pubsub() + if pubsub_name: + await pubsub.subscribe(pubsub_name) + if callable(join_cb): + await join_cb() + try: + await asyncio.gather( + self.process_websocket( + self.redis, web_socket, **(process_websockets_kwargs or {}) + ), + self.process_pubsub(pubsub, web_socket), + return_exceptions=True, + ) + except (CancelledError, WebSocketDisconnected): + pass + except Exception: + print_exception(*sys.exc_info()) + finally: + await web_socket.close() + if callable(leave_cb): + await leave_cb() + + +class WebSocketHub(BaseWebSocketHub): + def __init__(self, hubapp): + super().__init__() + self.hubapp = hubapp + + async def join_leave_client_notify(self, redis, action, client_id): + await redis.publish( + f"{self.hubapp.name}-master", + pickle.dumps({"action": action, "client_id": client_id}), + ) + + async def on_websocket_client(self, req, web_socket): + await self.client_ids_sem.acquire() + try: + client_id = await self.redis.incr("client_id") + finally: + self.client_ids_sem.release() + return await self.on_websocket( + req, + web_socket, + f"{self.hubapp.name}-client-{client_id}", + { + "extra_data": {"client_id": client_id}, + "recipients": [f"{self.hubapp.name}-master"], + }, + partial(self.join_leave_client_notify, self.redis, "join", client_id), + partial(self.join_leave_client_notify, self.redis, "leave", client_id), + ) + + async def on_websocket_master(self, req, web_socket): + return await self.on_websocket( + req, + web_socket, + f"{self.hubapp.name}-master", + {"recipients": self.get_master_recipients}, + ) + + def get_master_recipients(self, data): + return [ + f"{self.hubapp.name}-client-{int(client_id)}" + for client_id in data.pop("client_ids", ()) + ] diff --git a/hubapps/first/common.js b/hubapps/first/common.js deleted file mode 100644 index 74449f9..0000000 --- a/hubapps/first/common.js +++ /dev/null @@ -1,125 +0,0 @@ -(function () { - window.common = { - "HubClient": function (ws_uri, open, close, message) { - var ws_protocols = {"http:": "ws:", "https:": "wss:"}; - this.close = close.bind(this); - this.message = message.bind(this); - this.open = open.bind(this); - this.ws = new WebSocket( - ws_protocols[window.location.protocol] + - "//" + - window.location.host + - "/" + - ws_uri - ); - this.ws.addEventListener("close", this.close); - this.ws.addEventListener("message", this.message); - this.ws.addEventListener("open", this.open); - this.send = this.ws.send.bind(this.ws); - }, - "get_input": function (on_enter) { - var input = document.createElement("input"); - input.addEventListener("keydown", function (event) { - var keycode; - if (!event) { - event = window.event; - } - keycode = event.code || event.key; - if (keycode === "Enter" || keycode === "NumpadEnter") { - on_enter(); - } - }); - return input; - }, - "write": function (output, msg) { - if (output.childNodes.length > 0) { - output.append(document.createElement("br")); - } - if (typeof msg !== "string") { - msg = String(msg); - } - output.append(document.createTextNode(msg)); - }, - "setup": function (callback) { - document.addEventListener("readystatechange", function (event) { - if (!event) { - event = window.event; - } - if (event.target.readyState !== "complete") { - return; - } - callback(); - }); - }, - "ClientsList": function () { - this.ul = document.createElement("ul"); - document.body.append(this.ul); - this.get_label = function (client_id) { - return "client-" + client_id.toString(); - }; - this.get_name = function (client_id) { - var input = this.get_checkbox(client_id); - if (input !== null) { - return input.parentNode.childNodes[1].textContent; - } - return this.get_label(client_id); - }; - this.append = function (client_id) { - var li = document.createElement("li"); - var input = document.createElement("input"); - var client_label = this.get_label(client_id); - input.type = "checkbox"; - input.id = "checkbox-" + client_label; - input.checked = true; - li.append(input); - li.append(document.createTextNode(client_label)); - this.ul.append(li); - }; - this.get_checkbox = function (client_id) { - return document.getElementById("checkbox-" + this.get_label(client_id)); - }; - this.remove = function (client_id) { - var input = this.get_checkbox(client_id); - if (input === null) { - return; - } - input.parentNode.remove(); - }; - this.set_name = function(client_id, new_name) { - var input = this.get_checkbox(client_id); - var old_name; - if (input === null) { - return; - } - old_name = input.parentNode.childNodes[1].textContent; - while (input.parentNode.childNodes.length > 1) - input.parentNode.childNodes[1].remove(); - input.parentNode.append(document.createTextNode(new_name)); - input.parentNode.append( - document.createTextNode(" [" + client_id.toString() + "]") - ); - return old_name; - }; - this.all = function () { - var result = []; - Array.prototype.forEach.call(this.ul.children, function (child) { - var id_parts = child.getElementsByTagName("input")[0].id.split("-"); - result.push(Number(id_parts[id_parts.length - 1])); - }); - return result; - }; - this.selected = function () { - var result = []; - Array.prototype.forEach.call(this.ul.children, function (child) { - var input = child.getElementsByTagName("input")[0]; - var id_parts; - if (input.checked) { - id_parts = input.id.split("-"); - result.push(Number(id_parts[id_parts.length - 1])); - } - }); - return result; - }; - }, - }; -}()); diff --git a/hubapps/first/index.html b/hubapps/first/index.html deleted file mode 100644 index 7560554..0000000 --- a/hubapps/first/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - Index - - - - - - - - - diff --git a/hubapps/first/index.js b/hubapps/first/index.js deleted file mode 100644 index f6fa9c7..0000000 --- a/hubapps/first/index.js +++ /dev/null @@ -1,80 +0,0 @@ -(function () { - "use strict"; - var input_div = null; - - function write(msg) { - common.write(input_div.previousSibling, msg); - } - - function change_name_button(hub) { - var btn = document.createElement("button"); - btn.append(document.createTextNode("change name")); - btn.addEventListener("click", function () { - setup_change_name(hub); - }); - return btn; - } - - function setup_chat(hub) { - input_div = document.createElement("div"); - input_div.append(common.get_input(function () { - var input = input_div.getElementsByTagName("input")[0]; - hub.send(input.value); - input.value = ""; - })); - input_div.append(document.createTextNode(" ")); - input_div.append(change_name_button(hub)); - - document.body.append(document.createElement("div")); - document.body.append(input_div); - } - - function setup_change_name(hub) { - var div = document.createElement("div"); - div.append(document.createTextNode("Enter name: ")); - div.append(common.get_input(function () { - var client_name = div.children[0].value; - if (input_div.childNodes[0] !== input_div.children[0]) { - input_div.childNodes[0].remove(); - } - input_div.insertBefore( - document.createTextNode(client_name + " "), input_div.children[0] - ); - hub.send(JSON.stringify({"action": "set_name", "name": client_name})); - div.remove(); - input_div.style.display = ""; - input_div.children[0].focus(); - })); - document.body.append(div); - input_div.style.display = "none"; - div.children[0].focus(); - } - - function open() { - setup_chat(this); - write("connected to " + ws_uri); - setup_change_name(this); - } - - function close() { - write("connection lost"); - } - - function message(msg) { - var obj = JSON.parse(msg.data); - if (obj.action === "join" || obj.action === "leave") { - write("[action]: " + obj.name + " " + obj.action + "s"); - } else if (obj.action == "set_name") { - write( - "[action]: " + - obj.old_name + - " changes name to " + - obj.name - ); - } else if (obj.hasOwnProperty("data")) { - write(obj.name + ": " + obj.data); - } - } - - common.setup(function () {new common.HubClient(ws_uri, open, close, message)}); -})(); diff --git a/hubapps/first/master.html.tpl b/hubapps/first/master.html.tpl deleted file mode 100644 index 11ed0b1..0000000 --- a/hubapps/first/master.html.tpl +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - Master - - - - - - - - - diff --git a/hubapps/first/master.js b/hubapps/first/master.js deleted file mode 100644 index d6074e8..0000000 --- a/hubapps/first/master.js +++ /dev/null @@ -1,86 +0,0 @@ -(function () { - "use strict"; - var input_div = null; - var clients_list = null; - - function write(msg) { - common.write(input_div.previousSibling, msg); - } - function master_send(hub, client_ids, client_id, obj) { - obj.client_ids = client_ids; - obj.client_id = client_id; - hub.send(JSON.stringify(obj)); - } - - function setup_chat(hub) { - clients_list = new common.ClientsList(); - input_div = document.createElement("div"); - input_div.append(document.createTextNode("MASTER ")); - input_div.append(common.get_input(function () { - var input = input_div.getElementsByTagName("input")[0]; - master_send(hub, clients_list.selected(), null, { - "name": "MASTER", - "data": input.value, - }); - write("MASTER [null]: " + input.value); - input.value = ""; - })); - - document.body.append(document.createElement("div")); // output - document.body.append(input_div); - } - - function open() { - setup_chat(this); - write("connected to " + ws_uri); - } - - function close() { - write("connection lost"); - } - - function join_leave_broadcast(hub, client_id, client_name, action) { - write("[action]: " + client_name + " " + obj.action + "s"); - master_send(hub, clients_list.all(), obj.client_id, { - "action": obj.action, - "name": client_name, - }); - } - - function message(msg) { - var obj = JSON.parse(msg.data); - var client_name; - if (obj.action === "join") { - clients_list.append(obj.client_id); - join_leave_broadcast( - this, obj.client_id, clients_list.get_name(obj.client_id), obj.action - ); - } else if (obj.action === "leave") { - client_name = clients_list.get_name(obj.client_id); - clients_list.remove(obj.client_id); - join_leave_broadcast(this, obj.client_id, client_name, obj.action); - } else if (obj.action == "set_name") { - client_name = clients_list.set_name(obj.client_id, obj.name); - write( - "[action]: " + - client_name + - " changes name to " + - obj.name - ); - master_send(this, clients_list.all(), obj.client_id, { - "action": "set_name", - "old_name": client_name, - "name": obj.name, - }); - } else if (obj.hasOwnProperty("data")) { - client_name = clients_list.get_name(obj.client_id); - write(client_name + " [" + obj.client_id + "]: " + obj.data); - master_send(this, clients_list.all(), obj.client_id, { - "name": client_name, - "data": obj.data, - }); - } - } - - common.setup(function () {new common.HubClient(ws_uri, open, close, message)}); -})(); diff --git a/requirements.txt b/requirements.txt index 01e36a7..a9f2dd9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,23 +1,23 @@ -anyio==3.7.1 -black==23.7.0 -click==8.1.4 -falcon==3.1.1 +anyio==4.2.0 +black==23.12.1 +click==8.1.7 +falcon==3.1.3 h11==0.14.0 -httptools==0.6.0 -idna==3.4 -isort==5.12.0 +httptools==0.6.1 +idna==3.6 +isort==5.13.2 Jinja2==3.1.2 +jinja2-simple-tags==0.5.0 MarkupSafe==2.1.3 mypy-extensions==1.0.0 -packaging==23.1 -pathspec==0.11.1 -pipdeptree==2.11.0 -platformdirs==3.8.1 +packaging==23.2 +pathspec==0.12.1 +platformdirs==4.1.0 python-dotenv==1.0.0 PyYAML==6.0.1 -redis==4.6.0 +redis==5.0.1 sniffio==1.3.0 -uvicorn==0.23.1 -uvloop==0.17.0 -watchfiles==0.19.0 -websockets==11.0.3 +uvicorn==0.25.0 +uvloop==0.19.0 +watchfiles==0.21.0 +websockets==12.0 diff --git a/run_server.py b/run_server.py index 6ce1188..5da43ca 100755 --- a/run_server.py +++ b/run_server.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 +import asyncio + from hub.app import main if __name__ == "__main__": - main() + asyncio.run(main()) diff --git a/setup_venv.sh b/setup_venv.sh index 91fc1e7..139fde9 100644 --- a/setup_venv.sh +++ b/setup_venv.sh @@ -7,4 +7,5 @@ if [[ ! -d "${venv_dir}" ]]; then fi source "${venv_dir}/bin/activate" pip install -qU pip -pip install -qU 'uvicorn[standard]' falcon black pip redis isort Jinja2 +pip install -qU 'uvicorn[standard]' falcon black pip redis isort \ + Jinja2 jinja2-simple-tags diff --git a/webroot/first/common.js b/webroot/first/common.js new file mode 100644 index 0000000..57ae142 --- /dev/null +++ b/webroot/first/common.js @@ -0,0 +1,85 @@ +(function () { + var domconf_event_prefix = "event_"; + function domconf(domobj, obj) { + var key; + for (key in obj) { + if (key.substring(0, domconf_event_prefix.length) == domconf_event_prefix) { + domobj.addEventListener(key.substring(domconf_event_prefix.length), obj[key]); + } else { + domobj.setAttribute(key, obj[key]) + } + } + return domobj; + } + + function tag(tag_name, obj) { + return domconf(document.createElement(tag_name), obj); + } + + function HubClient(ws_uri, open, close, message) { + this.close = close.bind(this); + this.message = message.bind(this); + this.open = open.bind(this); + this.ws = domconf( + new WebSocket( + {"http:": "ws:", "https:": "wss:"}[window.location.protocol] + + "//" + + window.location.host + + ws_uri + ), + { + "event_close": this.close, + "event_message": this.message, + "event_open": this.open, + }, + ); + this.send = this.ws.send.bind(this.ws); + } + + function write(output, msg) { + if (output.childNodes.length > 0) { + output.append(tag("br")); + } + if (typeof msg !== "string") { + msg = String(msg); + } + output.append(document.createTextNode(msg)); + } + + function setup(callback) { + domconf( + document, + { + "event_readystatechange": function (event) { + if (!event) { + event = window.event; + } + if (event.target.readyState !== "complete") { + return; + } + callback(event); + } + }, + ); + } + + function make_key_event(callback) { + var wrapper = function (event) { + event = event || window.event; + callback(event, event.target || event.srcElement, event.code || event.key); + }; + return wrapper; + } + + function input_with_keydown_event(callback) { + return common.tag("input", {"event_keydown": make_key_event(callback)}); + } + + window.common = { + "tag": tag, + "HubClient": HubClient, + "write": write, + "setup": setup, + "input_with_keydown_event": input_with_keydown_event, + }; +}()); diff --git a/webroot/first/index.html.j2 b/webroot/first/index.html.j2 new file mode 100644 index 0000000..8ac692c --- /dev/null +++ b/webroot/first/index.html.j2 @@ -0,0 +1,18 @@ + + + + + + + Chat client + + + + + + +

Chat

+ + diff --git a/webroot/first/index.js b/webroot/first/index.js new file mode 100644 index 0000000..8cae34e --- /dev/null +++ b/webroot/first/index.js @@ -0,0 +1,97 @@ +(function () { + "use strict"; + var input_div = null; + + function write(msg) { + common.write(input_div.previousSibling, msg); + } + + function change_name_button(hub) { + var btn = common.tag( + "button", + { + "event_click": function () { + setup_change_name(hub); + } + } + ); + btn.append(document.createTextNode("change name")); + return btn; + } + + function setup_chat(hub) { + input_div = common.tag("div"); + input_div.append( + common.input_with_keydown_event( + function (event, target, keycode) { + if (keycode !== "Enter" && keycode !== "NumpadEnter") { + return; + } + hub.send(JSON.stringify({"data": target.value})); + target.value = ""; + } + ) + ); + input_div.append(document.createTextNode(" ")); + input_div.append(change_name_button(hub)); + + document.body.append(common.tag("div")); + document.body.append(input_div); + } + + function setup_change_name(hub) { + var div = common.tag("div"); + div.append(document.createTextNode("Enter name: ")); + div.append( + common.input_with_keydown_event( + function (event, target, keycode) { + if (keycode !== "Enter" && keycode !== "NumpadEnter") { + return; + } + if (input_div.childNodes[0] !== input_div.children[0]) { + input_div.childNodes[0].remove(); + } + input_div.insertBefore( + document.createTextNode(target.value + " "), input_div.children[0] + ); + hub.send(JSON.stringify({"action": "set_name", "name": target.value})); + div.remove(); + input_div.style.display = ""; + input_div.children[0].focus(); + } + ) + ) + document.body.append(div); + input_div.style.display = "none"; + div.children[0].focus(); + } + + function open() { + setup_chat(this); + write("connected to " + ws_uri); + setup_change_name(this); + } + + function close() { + write("connection lost"); + input_div.remove(); + } + + function message(msg) { + var obj = JSON.parse(msg.data); + if (obj.action === "join" || obj.action === "leave") { + write("[action]: " + obj.name + " " + obj.action + "s"); + } else if (obj.action == "set_name") { + write( + "[action]: " + + obj.old_name + + " changes name to " + + obj.name + ); + } else if (obj.hasOwnProperty("data")) { + write(obj.name + ": " + obj.data); + } + } + + common.setup(function () {new common.HubClient(ws_uri, open, close, message)}); +})(); diff --git a/webroot/first/master.html.j2 b/webroot/first/master.html.j2 new file mode 100644 index 0000000..4de2a38 --- /dev/null +++ b/webroot/first/master.html.j2 @@ -0,0 +1,18 @@ + + + + + + + Chat Master + + + + + + +

Chat Master

+ + diff --git a/webroot/first/master.js b/webroot/first/master.js new file mode 100644 index 0000000..ada5f96 --- /dev/null +++ b/webroot/first/master.js @@ -0,0 +1,166 @@ +(function () { + "use strict"; + var input_div = null; + var clients_list = null; + + function write(msg) { + common.write(input_div.previousSibling, msg); + } + + function master_send(hub, client_ids, client_id, obj) { + obj.client_ids = client_ids; + obj.client_id = client_id; + hub.send(JSON.stringify(obj)); + } + + function ClientsList() { + function Client(clients_list, client_id, name) { + var client_label; + + this.clients_list = clients_list; + this.id = client_id; + this.get_label = function () { + return "client-" + this.id.toString(); + }; + client_label = this.get_label(); + this.name = name || client_label; + + this.li = common.tag("li") + this.checkbox = common.tag( + "input", + { + "type": "checkbox", + "id": "checkbox-" + client_label, + "checked": "", + }, + ); + this.li.append(this.checkbox); + clients_list.ul.append(this.li); + this.set_name = function (new_name) { + var old_name = this.name; + this.name = new_name; + while (this.checkbox.nextSibling) + this.checkbox.nextSibling.remove(); + this.checkbox.parentNode.append(document.createTextNode(new_name)); + return old_name; + }; + this.set_name(client_label); + } + this.clients = {}; + this.ul = common.tag("ul"); + document.body.append(this.ul); + + this.append = function (client_id) { + var client = new Client(this, client_id); + this.clients[client_id.toString()] = client; + return client; + }; + this.remove = function (client_id) { + var client = this.clients[client_id]; + if (client) { + client.li.remove(); + delete this.clients[client_id]; + } + return client; + }; + this.all = function () { + var key; + var result = []; + for (key in this.clients) { + result.push(this.clients[key].id); + } + return result; + }; + this.selected = function () { + var key; + var client; + var result = []; + for (key in this.clients) { + client = this.clients[key]; + if (client.checkbox.checked) { + result.push(client.id); + } + } + return result; + }; + } + + function open() { + var hub = this; + clients_list = new ClientsList(); + input_div = common.tag("div"); + input_div.append(document.createTextNode("MASTER ")); + input_div.append( + common.input_with_keydown_event( + function (event, target, keycode) { + if (keycode !== "Enter" && keycode !== "NumpadEnter") { + return; + } + hub.send(JSON.stringify({ + "client_ids": clients_list.selected(), + "name": "MASTER", + "data": target.value, + })); + write("MASTER: " + target.value); + target.value = ""; + } + ) + ); + + document.body.append(common.tag("div")); // output + document.body.append(input_div); + write("connected to " + ws_uri); + } + + function close() { + write("connection lost"); + input_div.remove(); + } + + function join_leave_broadcast(hub, client, action) { + if (!client) { + return; + } + write("[action]: " + client.name + " " + action + "s"); + master_send(hub, clients_list.all(), client.id, { + "action": action, + "name": client.name, + }); + } + + function message(msg) { + var obj = JSON.parse(msg.data); + var client; + if (obj.action === "join") { + join_leave_broadcast(this, clients_list.append(obj.client_id), obj.action); + return; + } else if (obj.action === "leave") { + join_leave_broadcast(this, clients_list.remove(obj.client_id), obj.action); + return; + } + client = clients_list.clients[obj.client_id.toString()]; + + if (obj.action == "set_name") { + write( + "[action]: " + + client.name + + " changes name to " + + obj.name + ); + master_send(this, clients_list.all(), client.id, { + "action": "set_name", + "old_name": client.name, + "name": obj.name, + }); + client.set_name(obj.name); + } else if (obj.hasOwnProperty("data")) { + write(client.name + " [" + client.id + "]: " + obj.data); + master_send(this, clients_list.all(), client.id, { + "name": client.name, + "data": obj.data, + }); + } + } + + common.setup(function () { new common.HubClient(ws_uri, open, close, message); }); +})(); diff --git a/webroot/index.html.j2 b/webroot/index.html.j2 new file mode 100644 index 0000000..8203a3d --- /dev/null +++ b/webroot/index.html.j2 @@ -0,0 +1,22 @@ + + + + + + + Hubapp Index + + + +

App list

+ + + diff --git a/webroot/style.css b/webroot/style.css new file mode 100644 index 0000000..4f7171b --- /dev/null +++ b/webroot/style.css @@ -0,0 +1,4 @@ +li span { + display: inline-block; + width: 7em; +} -- 2.45.2