From c0e574584af0d45070e5fa81fcbcd1dccc2c5a42 Mon Sep 17 00:00:00 2001 From: mar77i Date: Fri, 4 Aug 2023 01:03:14 +0200 Subject: [PATCH] functioning basic chat app --- hub/app.py | 71 +++++++++- hub/cryptresource.py | 46 ------- hub/hub.py | 102 ++++++++++++++ hub/staticresource.py | 83 ++++++++++++ hub/utils.py | 18 +++ hubapps/first/common.js | 125 ++++++++++++++++++ hubapps/first/index.html | 18 +++ hubapps/first/index.html.j2 | 14 -- hubapps/first/index.js | 87 ++++++++++-- .../first/{master.html.j2 => master.html.tpl} | 6 +- hubapps/first/master.js | 95 +++++++++++-- requirements.txt | 23 ++++ run_server.py | 4 +- setup_venv.sh | 10 ++ 14 files changed, 612 insertions(+), 90 deletions(-) delete mode 100644 hub/cryptresource.py create mode 100644 hub/hub.py create mode 100644 hub/staticresource.py create mode 100644 hub/utils.py create mode 100644 hubapps/first/common.js create mode 100644 hubapps/first/index.html delete mode 100644 hubapps/first/index.html.j2 rename hubapps/first/{master.html.j2 => master.html.tpl} (70%) create mode 100644 requirements.txt create mode 100644 setup_venv.sh diff --git a/hub/app.py b/hub/app.py index 24212d0..8dfb542 100644 --- a/hub/app.py +++ b/hub/app.py @@ -1,16 +1,77 @@ from argparse import ArgumentParser +from itertools import chain +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.constants import MEDIA_HTML +from uvicorn import Config, Server -from .cryptresource import CryptResource +from .staticresource import StaticResource +from .hub import Hub + + +class HubApp(App): + def __init__(self, secret, browser, hubapp, *args, **kwargs): + kwargs.setdefault("media_type", MEDIA_HTML) + super().__init__(*args, **kwargs) + self.secret = secret or token_urlsafe(64) + self.hub = Hub(self.secret) + self.sr = StaticResource(self.secret, hubapp, self.hub.master_ws_uri) + self.browser = browser + self.hub.add_routes(self) + self.sr.add_routes(self) + self.add_error_handler(Exception, self.print_exception) + + async def print_exception(self, req, resp, ex, params): + print_exception(*sys.exc_info()) def get_app(): - app = App(media_type=MEDIA_HTML) ap = ArgumentParser() ap.add_argument("--secret") + ap.add_argument("--browser", default="firefox") + ap.add_argument("--hubapp", default="first") args = ap.parse_args() - cr = CryptResource(args.secret) - cr.register_on(app) - return app + return HubApp(args.secret, args.browser, args.hubapp) + + +class HubServer(Server): + async def startup(self, sockets: Optional[List[socket.socket]] = None) -> None: + await super().startup(sockets) + config = self.config + protocol_name = "https" if config.ssl else "http" + host = "0.0.0.0" if config.host is None else config.host + if ":" in host: + # It's an IPv6 address. + host = f"[{host.rstrip(']').lstrip('[')}]" + + port = config.port + if port == 0: + try: + port = next( + 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]) + + +def main(): + HubServer( + Config("hub.app:get_app", factory=True, port=5000, log_level="info") + ).run() diff --git a/hub/cryptresource.py b/hub/cryptresource.py deleted file mode 100644 index ce2dc5c..0000000 --- a/hub/cryptresource.py +++ /dev/null @@ -1,46 +0,0 @@ -from base64 import urlsafe_b64encode -from hashlib import sha3_512 -from pathlib import Path -from secrets import token_urlsafe - -from falcon.constants import MEDIA_JS -from jinja2 import Environment, FileSystemLoader - - -class CryptResource: - @staticmethod - def scramble(secret, value): - h = sha3_512() - h.update(f"{secret}{value}".encode()) - return urlsafe_b64encode(h.digest()).rstrip(b'=').decode('ascii') - - def __init__(self, secret=None): - self.current_hubapp = "first" - self.secret = secret or token_urlsafe(64) - self.master_uri = f"/{self.scramble(self.secret, 'index.html')}" - self.master_js_uri = f"/{self.scramble(self.secret, 'master.js')}" - hubapp_dir = Path(__file__).parents[1] / "hubapps" / self.current_hubapp - self.env = Environment(loader=FileSystemLoader(hubapp_dir)) - with open(hubapp_dir / "index.js", "rb") as fh: - self._index_js = fh.read() - with open(hubapp_dir / "master.js", "rb") as fh: - self._master_js = fh.read() - - async def on_get(self, req, resp): - resp.data = self.env.get_template("index.html.j2").render({}).encode() - - async def on_get_index_js(self, req, resp): - resp.data = self.env.get_template("index.js").render({}).encode() - - async def on_get_master(self, req, resp): - resp.data = self.env.get_template("master.html.j2").render({"master_js": self.master_js_uri}).encode() - - async def on_get_master_js(self, req, resp): - resp.data = self._master_js - resp.content_type = MEDIA_JS - - def register_on(self, app): - app.add_route("/", self) - app.add_route("/index.js", self, suffix="index_js") - app.add_route(self.master_uri, self, suffix="master") - app.add_route(self.master_js_uri, self, suffix="master_js") diff --git a/hub/hub.py b/hub/hub.py new file mode 100644 index 0000000..0eee3f8 --- /dev/null +++ b/hub/hub.py @@ -0,0 +1,102 @@ +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") diff --git a/hub/staticresource.py b/hub/staticresource.py new file mode 100644 index 0000000..9cc949a --- /dev/null +++ b/hub/staticresource.py @@ -0,0 +1,83 @@ +import json +import mimetypes +import os +from pathlib import Path + +from .utils import scramble + + +class StaticFile: + template_ext = ".tpl" + + 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): + if self.path.stat().st_mtime != self.mtime: + self.content = self.load_content() + return self.content + + def render(self, context_vars): + content = self.get() + if self.path.suffix == self.template_ext: + for k, v in context_vars.items(): + content = content.replace(b"{{ %s }}" % k.encode(), v.encode()) + return content + + 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, master_ws_uri): + mimetypes.init() + hub_js_path = Path(__file__).parent / "hub.js" + self.static_files = { + "/hub.js": StaticFile(hub_js_path), + } + self.context_vars = { + "master_ws_uri": json.dumps(master_ws_uri), + } + 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.render(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 new file mode 100644 index 0000000..c9d955c --- /dev/null +++ b/hub/utils.py @@ -0,0 +1,18 @@ +from base64 import urlsafe_b64encode +from hashlib import sha3_512 + + +def scramble(secret, value): + h = sha3_512() + h.update(f"{secret}{value}".encode()) + return urlsafe_b64encode(h.digest()).rstrip(b"=").decode("ascii") + + +def get_redis_pass(redis_conf): + prefix = "requirepass " + with open(redis_conf, "rt") as fh: + for line in fh: + if not line.startswith(prefix): + continue + return line[len(prefix) :].rstrip() + return None diff --git a/hubapps/first/common.js b/hubapps/first/common.js new file mode 100644 index 0000000..74449f9 --- /dev/null +++ b/hubapps/first/common.js @@ -0,0 +1,125 @@ +(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 new file mode 100644 index 0000000..7560554 --- /dev/null +++ b/hubapps/first/index.html @@ -0,0 +1,18 @@ + + + + + + + Index + + + + + + + + + diff --git a/hubapps/first/index.html.j2 b/hubapps/first/index.html.j2 deleted file mode 100644 index dfc36eb..0000000 --- a/hubapps/first/index.html.j2 +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - Index - - - - - hello, this is index.html, the "controller" - - diff --git a/hubapps/first/index.js b/hubapps/first/index.js index 20d7101..f6fa9c7 100644 --- a/hubapps/first/index.js +++ b/hubapps/first/index.js @@ -1,13 +1,80 @@ -"use strict"; - (function () { - document.addEventListener("readystatechange", function (event) { - if (!event) { - event = window.event; - } - if (event.target.readyState !== "complete") { - return; + "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); } - document.body.append(document.createTextNode("(this is index.js) ")); - }); + } + + common.setup(function () {new common.HubClient(ws_uri, open, close, message)}); })(); diff --git a/hubapps/first/master.html.j2 b/hubapps/first/master.html.tpl similarity index 70% rename from hubapps/first/master.html.j2 rename to hubapps/first/master.html.tpl index 12dfc85..7a00c93 100644 --- a/hubapps/first/master.html.j2 +++ b/hubapps/first/master.html.tpl @@ -8,7 +8,11 @@ + + + - hello, this is master.html, the "playing field" diff --git a/hubapps/first/master.js b/hubapps/first/master.js index e33e9fe..d6074e8 100644 --- a/hubapps/first/master.js +++ b/hubapps/first/master.js @@ -1,15 +1,86 @@ -"use strict"; - (function () { - document.addEventListener("readystatechange", function (event) { - if (!event) { - event = window.event; - } - if (event.target.readyState !== "complete") { - return; + "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, + }); } - document.body.append(document.createTextNode("(this is master.js) ")); - // hide real url - history.pushState({}, "", new URL(window.location.origin + "/")); - }); + } + + common.setup(function () {new common.HubClient(ws_uri, open, close, message)}); })(); diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..01e36a7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,23 @@ +anyio==3.7.1 +black==23.7.0 +click==8.1.4 +falcon==3.1.1 +h11==0.14.0 +httptools==0.6.0 +idna==3.4 +isort==5.12.0 +Jinja2==3.1.2 +MarkupSafe==2.1.3 +mypy-extensions==1.0.0 +packaging==23.1 +pathspec==0.11.1 +pipdeptree==2.11.0 +platformdirs==3.8.1 +python-dotenv==1.0.0 +PyYAML==6.0.1 +redis==4.6.0 +sniffio==1.3.0 +uvicorn==0.23.1 +uvloop==0.17.0 +watchfiles==0.19.0 +websockets==11.0.3 diff --git a/run_server.py b/run_server.py index 818817d..6ce1188 100755 --- a/run_server.py +++ b/run_server.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -import uvicorn +from hub.app import main if __name__ == "__main__": - uvicorn.run("hub.app:get_app", factory=True, port=5000, log_level="info") + main() diff --git a/setup_venv.sh b/setup_venv.sh new file mode 100644 index 0000000..91fc1e7 --- /dev/null +++ b/setup_venv.sh @@ -0,0 +1,10 @@ +venv_dir="${VIRTUAL_ENV:-venv}" +if type deactivate &>/dev/null; then + deactivate +fi +if [[ ! -d "${venv_dir}" ]]; then + "${PYTHON:-python}" -m venv "${venv_dir}" +fi +source "${venv_dir}/bin/activate" +pip install -qU pip +pip install -qU 'uvicorn[standard]' falcon black pip redis isort Jinja2 -- 2.45.2