import socket
import sys
from argparse import ArgumentParser
+from base64 import urlsafe_b64encode
+from hashlib import pbkdf2_hmac
from pathlib import Path
+from secrets import token_urlsafe
from subprocess import run
from traceback import print_exception
from urllib.parse import urlunsplit
from falcon.asgi import App as FalconApp
from falcon.constants import MEDIA_HTML
from jinja2 import Environment, FileSystemLoader, select_autoescape
+from redis.asyncio import StrictRedis
from uvicorn import Config, Server
-from .hubapp import RootApp, HubApp
+from .static import HubApp, TemplateTreeFileApp
-class App(FalconApp):
- def __init__(self, secret, **kwargs):
+class App(TemplateTreeFileApp, FalconApp):
+ @classmethod
+ def scan_files(cls, base_dir):
+ for path in base_dir.iterdir():
+ if not path.is_dir() and not cls.is_ignored_filename(path):
+ yield path
+
+ def scramble(self, value):
+ if isinstance(value, str):
+ value = value.encode()
+ secret = self.secret
+ if isinstance(secret, str):
+ secret = secret.encode()
+ return urlsafe_b64encode(
+ pbkdf2_hmac("sha512", value, secret, 221100)
+ ).rstrip(b"=").decode("ascii")
+
+ def uri_tail(self, path: Path) -> str:
+ return self.scramble(super().uri_tail(path))
+
+ def __init__(self, base_dir, secret, **kwargs):
+ from .utils import get_redis_pass
+
+ self.secret = secret or token_urlsafe(64)
kwargs.setdefault("media_type", MEDIA_HTML)
- super().__init__(**kwargs)
- self.base_dir = Path(__file__).parents[1] / "webroot"
+ FalconApp.__init__(self, **kwargs)
+ TemplateTreeFileApp.__init__(
+ self, self, base_dir, "/derp", "root"
+ )
self.env = Environment(
loader=FileSystemLoader(self.base_dir),
autoescape=select_autoescape(),
extensions=["hub.utils.StaticTag"],
)
- self.hubapps = {"root": RootApp(self, self.base_dir, "/derp", secret)}
+ self.hubapps = {}
+ self.conn = StrictRedis(
+ username="default", password=get_redis_pass("/etc/redis/redis.conf")
+ )
for base_dir in self.base_dir.iterdir():
- if not base_dir.is_dir() or RootApp.is_ignored_filename(base_dir):
- continue
- self.hubapps[base_dir.name] = HubApp(
- self, base_dir, base_uri=f"/derp/{base_dir.name}"
- )
+ if base_dir.is_dir() and not self.is_ignored_filename(base_dir):
+ self.hubapps[base_dir.name] = HubApp(
+ self, base_dir, base_uri=f"/derp/{base_dir.name}"
+ )
self.add_error_handler(Exception, self.print_exception)
async def print_exception(self, req, resp, ex, params):
async def startup(self, sockets: list[socket.socket] | None = None) -> None:
await super().startup(sockets)
- root_app = self.config.loaded_app.app.hubapps["root"]
+ root_app = self.config.loaded_app.app
print("Secret:", root_app.secret)
for uri, file in root_app.files_per_uris.items():
if file.name == "index.html":
ap.add_argument("--secret")
ap.add_argument("--browser", default="xdg-open")
args = ap.parse_args()
- app = App(args.secret)
- await app.hubapps["root"].setup()
+ app = App(Path(__file__).parents[1] / "webroot", args.secret)
+ await app.conn.set("client_id", 0)
config = Config(app, port=5000, log_level="info")
config.setup_event_loop()
hs = HubServer(config, browser=args.browser)
-from base64 import urlsafe_b64encode
-from hashlib import pbkdf2_hmac
from pathlib import Path
-from secrets import token_urlsafe
from falcon.constants import MEDIA_HTML, MEDIA_JS, MEDIA_TEXT
from falcon.status_codes import HTTP_OK
from jinja2 import Template
-from redis.asyncio import StrictRedis
from .websocket import WebSocketApp
Map a directory tree base_dir to a base_uri and serve it statically.
Map index.html files to their relative roots:
index.html to "/" (root uri) to index.html and
- "/a" (directory uri) to a/index.html
+ a/index.html to "/a" (directory uri)
"""
@staticmethod
def is_ignored_filename(path):
uri_tail = uri_tail[:-len(index_suffix)]
return f"{self.base_uri}/{uri_tail.lstrip('/')}"
- def __init__(self, app, base_dir, base_uri="/"):
- self.app = app
+ @staticmethod
+ def create_name(root_base_uri, base_uri):
+ if base_uri.startswith(root_base_uri):
+ base_uri = base_uri[len(root_base_uri):]
+ return base_uri.replace("/", ".").strip(".")
+
+ def __init__(self, root, base_dir, base_uri="/", name=None):
+ self.root = root
self.base_dir = base_dir
self.base_uri = base_uri.rstrip("/")
- self.name = self.base_uri.replace("/", ".").strip(".") or "root"
+ self.name = name or self.create_name(self.root.base_uri, self.base_uri)
+ assert self.name, self.name
self.files_per_uris = {}
for path in self.scan_files(base_dir):
static_file = self.get_file(path)
uri = self.uri(static_file.path)
self.files_per_uris[uri] = static_file
- app.add_route(uri, self)
+ root.add_route(uri, self)
async def on_get(self, req, resp):
resource = self.files_per_uris[req.path]
def get(self) -> str:
mtime = self.path.stat().st_mtime
if mtime != self.mtime:
- env = self.hubapp.app.env
+ env = self.hubapp.root.env
self.content = env.get_template(
str(self.path.relative_to(env.loader.searchpath[0]))
)
return uri_tail
-class RootApp(TemplateTreeFileApp):
- @classmethod
- def scan_files(cls, base_dir):
- for path in base_dir.iterdir():
- if not path.is_dir() and not cls.is_ignored_filename(path):
- yield path
-
- def __init__(self, app, base_dir, base_uri="/", secret=None):
- from .utils import get_redis_pass
-
- self.secret = secret or token_urlsafe(64)
- super().__init__(app, base_dir, base_uri)
- self.conn = StrictRedis(username="default", password=get_redis_pass("/etc/redis/redis.conf"))
-
- async def setup(self):
- await self.conn.set("client_id", 0)
-
- def scramble(self, value):
- if isinstance(value, str):
- value = value.encode()
- secret = self.secret
- if isinstance(secret, str):
- secret = secret.encode()
- return urlsafe_b64encode(
- pbkdf2_hmac("sha512", value, secret, 221100)
- ).rstrip(b"=").decode("ascii")
-
- def uri_tail(self, path: Path) -> str:
- return self.scramble(super().uri_tail(path))
-
-
class HubApp(TemplateTreeFileApp):
def __init__(self, app, base_dir, base_uri="/"):
super().__init__(app, base_dir, base_uri)
self.ws_app = WebSocketApp(self)
- self.ws_uris = {
- self.uri(Path(f"ws_{value}")): value for value in ("client", "master")
- }
- for uri, suffix in self.ws_uris.items():
+ for suffix in ("client", "master"):
+ uri = self.uri(Path(f"ws_{suffix}"))
+ self.files_per_uris[uri] = self.ws_app
app.add_route(uri, self.ws_app, suffix=suffix)
@staticmethod
pos = basename.find(".")
if pos != -1:
basename = basename[:pos]
- return basename in "master"
+ return basename in ("master", "ws_master")
def uri(self, path: Path) -> str:
uri = super().uri(path)
- if self.is_master_uri(path):
- pos = uri.rstrip("/").rfind("/")
- scrambled_uri = self.app.hubapps["root"].scramble(uri)
- if pos == -1:
- uri = scrambled_uri
- else:
- uri = f"{uri[:pos]}/{scrambled_uri}"
- return uri
+ if not self.is_master_uri(path):
+ return uri
+ pos = uri.rstrip("/").rfind("/")
+ assert pos >= 0
+ scrambled_uri = self.root.scramble(uri)
+ return f"{uri[:pos]}/{scrambled_uri}"
from jinja2_simple_tags import StandaloneTag
-from .hubapp import TreeFileApp
+from .static import TreeFileApp
class StaticTag(StandaloneTag):
tags = {"static"}
+ @staticmethod
+ def get_hubapp(static_file, hubapp):
+ h = static_file.hubapp
+ if hubapp == "root":
+ return h.root
+ elif isinstance(hubapp, str):
+ return h.root.hubapps[hubapp]
+ elif isinstance(hubapp, TreeFileApp):
+ return hubapp
+ return static_file.hubapp
+
def render(
self, filename: str = "", hubapp: str | TreeFileApp | None = None
- ):
+ ) -> str:
"""
If filename starts with '/', interpret the path as relative to hubapp.base_dir,
otherwise assume the path is relative to the current file.
"""
static_file = self.context["static_file"]
- h = static_file.hubapp
- if isinstance(hubapp, str):
- h = h.app.hubapps[hubapp]
- elif isinstance(hubapp, TreeFileApp):
- h = hubapp
- del hubapp
- if filename.startswith("/") or h != static_file.hubapp:
- path = h.base_dir / filename.lstrip("/")
+ hubapp = self.get_hubapp(static_file, hubapp)
+ if filename.startswith("/") or hubapp != static_file.hubapp:
+ path = hubapp.base_dir / filename.lstrip("/")
else:
path = static_file.path.parent / filename
- return h.uri(path)
+ return hubapp.uri(path)
-def get_redis_pass(redis_conf):
+def get_redis_pass(redis_conf: str) -> str:
"""
Poor man's redis credentials: read the password from redis_conf.
Requires redis being configured with a `requirepass` password set.
from falcon import WebSocketDisconnected
-class BaseWebSocketApp:
+class WebSocketApp:
def __init__(self, hubapp):
- self.hubapp = hubapp
- self.conn = self.hubapp.app.hubapps["root"].conn
-
- def task_done(self):
- self.task = None
+ self.name = hubapp.name
+ self.conn = hubapp.root.conn
@staticmethod
- async def process_websocket(conn, web_socket, extra_data={}, recipients=[]):
+ async def process_websocket(conn, web_socket, extra_data=None, recipients=None):
try:
while True:
data = json.loads(await web_socket.receive_text())
- data.update(extra_data)
+ if extra_data:
+ data.update(extra_data)
if callable(recipients):
current_recipients = recipients(data)
- else:
+ elif recipients:
current_recipients = recipients
+ else:
+ raise ValueError("no recipients specified")
for recipient in current_recipients:
await conn.publish(recipient, pickle.dumps(data))
except (CancelledError, WebSocketDisconnected):
if callable(leave_cb):
await leave_cb()
-
-class WebSocketApp(BaseWebSocketApp):
- async def join_leave_client_notify(self, redis, action, client_id):
+ async def client_notify(self, redis, action, client_id):
await redis.publish(
- f"{self.hubapp.name}-master",
+ f"{self.name}-master",
pickle.dumps({"action": action, "client_id": client_id}),
)
return await self.on_websocket(
req,
web_socket,
- f"{self.hubapp.name}-client-{client_id}",
+ f"{self.name}-client-{client_id}",
{
"extra_data": {"client_id": client_id},
- "recipients": [f"{self.hubapp.name}-master"],
+ "recipients": [f"{self.name}-master"],
},
- partial(self.join_leave_client_notify, self.conn, "join", client_id),
- partial(self.join_leave_client_notify, self.conn, "leave", client_id),
+ partial(self.client_notify, self.conn, "join", client_id),
+ partial(self.client_notify, self.conn, "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",
+ f"{self.name}-master",
{"recipients": self.get_master_recipients},
)
def get_master_recipients(self, data):
return [
- f"{self.hubapp.name}-client-{int(client_id)}"
+ f"{self.name}-client-{int(client_id)}"
for client_id in data.pop("client_ids", ())
]
<body>
<script src="{% static 'common.js', 'root' %}"></script>
<script type="text/javascript">
- var ws_uri = "{% static 'ws_master' %}";
+ var ws_uri = "{% static '/ws_master' %}";
</script>
<script src="{% static 'master.js' %}"></script>
<h1>Chat Master</h1>
document.body.append(common.tag("div")); // output
document.body.append(input_div);
- write("connected to " + ws_uri);
+ write("connected to ws_master");
}
function close() {
<body>
<h1>App list</h1>
<ul>
- {% for name, hubapp in static_file.hubapp.app.hubapps.items() if name != "root" %}
+ {% for name, hubapp in static_file.hubapp.hubapps.items() %}
<li>
<span>{{ name }}</span>
<span><a href="{% static '/master.html', hubapp %}">master</a></span>