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()
+++ /dev/null
-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")
--- /dev/null
+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")
--- /dev/null
+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"<StaticFile at \"{os.sep.join(self.path.parts[-2:])}\" [{self.mime_type}]>"
+
+ @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)
--- /dev/null
+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
--- /dev/null
+(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;
+ };
+ },
+ };
+}());
--- /dev/null
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <meta http-equiv="X-UA-Compatible" content="ie=edge">
+ <title>Index</title>
+ <!-- <link rel="stylesheet" href="style.css"> -->
+ </head>
+ <body>
+ <script src="common.js"></script>
+ <script src="hub.js"></script>
+ <script type="text/javascript">
+ var ws_uri = "/ws";
+ </script>
+ <script src="index.js"></script>
+ </body>
+</html>
+++ /dev/null
-<!DOCTYPE html>
-<html lang="en">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <meta http-equiv="X-UA-Compatible" content="ie=edge">
- <title>Index</title>
- <!-- <link rel="stylesheet" href="style.css"> -->
- </head>
- <body>
- <script src="index.js"></script>
- hello, this is index.html, the "controller"
- </body>
-</html>
-"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)});
})();
<!-- <link rel="stylesheet" href="style.css"> -->
</head>
<body>
+ <script src="common.js"></script>
+ <script src="hub.js"></script>
+ <script type="text/javascript">
+ var ws_uri = {{ master_ws_uri }};
+ </script>
<script src="{{ master_js }}"></script>
- hello, this is master.html, the "playing field"
</body>
</html>
-"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)});
})();
--- /dev/null
+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
#!/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()
--- /dev/null
+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