From 71586420b62304382c339e90b70a0bd85e874ae7 Mon Sep 17 00:00:00 2001 From: n9k Date: Sun, 13 Feb 2022 04:00:10 +0000 Subject: [PATCH] Project structure, chat markup/style, websockets --- .gitignore | 1 + README.md => anonstream/README.md | 0 anonstream/__init__.py | 30 +++++ anonstream/chat.py | 17 +++ anonstream/routes.py | 34 ++++++ anonstream/static/anonstream.js | 143 ++++++++++++++++++++++++ {static => anonstream/static}/style.css | 123 +++++++++++++++++--- anonstream/templates/home.html | 29 +++++ anonstream/templates/info.html | 10 ++ anonstream/utils/chat.py | 11 ++ anonstream/utils/token.py | 4 + anonstream/utils/websocket.py | 19 ++++ anonstream/websocket.py | 50 +++++++++ anonstream/wrappers.py | 38 +++++++ app.py | 13 +-- config.toml | 2 + templates/home.html | 22 ---- 17 files changed, 503 insertions(+), 43 deletions(-) create mode 100644 .gitignore rename README.md => anonstream/README.md (100%) create mode 100644 anonstream/__init__.py create mode 100644 anonstream/chat.py create mode 100644 anonstream/routes.py create mode 100644 anonstream/static/anonstream.js rename {static => anonstream/static}/style.css (52%) create mode 100644 anonstream/templates/home.html create mode 100644 anonstream/templates/info.html create mode 100644 anonstream/utils/chat.py create mode 100644 anonstream/utils/token.py create mode 100644 anonstream/utils/websocket.py create mode 100644 anonstream/websocket.py create mode 100644 anonstream/wrappers.py create mode 100644 config.toml delete mode 100644 templates/home.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/README.md b/anonstream/README.md similarity index 100% rename from README.md rename to anonstream/README.md diff --git a/anonstream/__init__.py b/anonstream/__init__.py new file mode 100644 index 0000000..8268c2d --- /dev/null +++ b/anonstream/__init__.py @@ -0,0 +1,30 @@ +import secrets +import toml +from collections import OrderedDict + +from quart import Quart +from werkzeug.security import generate_password_hash + +from anonstream.utils.token import generate_token + +async def create_app(): + with open('config.toml') as fp: + config = toml.load(fp) + + auth_password = secrets.token_urlsafe(6) + auth_pwhash = generate_password_hash(auth_password) + print('Broadcaster username:', config['auth_username']) + print('Broadcaster password:', auth_password) + + app = Quart('anonstream') + app.config['SECRET_KEY'] = config['secret'].encode() + app.config['AUTH_USERNAME'] = config['auth_username'] + app.config['AUTH_PWHASH'] = auth_pwhash + app.config['AUTH_TOKEN'] = generate_token() + app.chat = OrderedDict() + app.websockets = {} + + async with app.app_context(): + import anonstream.routes + + return app diff --git a/anonstream/chat.py b/anonstream/chat.py new file mode 100644 index 0000000..3a6cd09 --- /dev/null +++ b/anonstream/chat.py @@ -0,0 +1,17 @@ +from datetime import datetime + +from quart import escape + +def add_chat_message(chat, message_id, token, text): + dt = datetime.utcnow() + markup = escape(text) + chat[message_id] = { + 'id': message_id, + 'token': token, + 'timestamp': int(dt.timestamp()), + 'date': dt.strftime('%Y-%m-%d'), + 'time_minutes': dt.strftime('%H:%M'), + 'time_seconds': dt.strftime('%H:%M:%S'), + 'nomarkup': text, + 'markup': markup, + } diff --git a/anonstream/routes.py b/anonstream/routes.py new file mode 100644 index 0000000..ff21818 --- /dev/null +++ b/anonstream/routes.py @@ -0,0 +1,34 @@ +import asyncio + +from quart import current_app, request, render_template, redirect, websocket + +from anonstream.wrappers import with_token_from, auth_required +from anonstream.websocket import websocket_outbound, websocket_inbound + +@current_app.route('/') +@with_token_from(request) +async def home(token): + return await render_template('home.html', token=token) + +@current_app.route('/login') +@auth_required +async def login(): + return redirect('/') + +@current_app.websocket('/live') +@with_token_from(websocket) +async def live(token): + queue = asyncio.Queue() + current_app.websockets[token] = queue + + producer = websocket_outbound(queue) + consumer = websocket_inbound( + secret=current_app.config['SECRET_KEY'], + connected_websockets=current_app.websockets, + chat=current_app.chat, + token=token, + ) + try: + await asyncio.gather(producer, consumer) + finally: + current_app.websockets.pop(token) diff --git a/anonstream/static/anonstream.js b/anonstream/static/anonstream.js new file mode 100644 index 0000000..f79b2aa --- /dev/null +++ b/anonstream/static/anonstream.js @@ -0,0 +1,143 @@ +/* insert js-only markup */ +const jsmarkup_info_title = '
'; +const jsmarkup_chat_messages = ''; +const jsmarkup_chat_form = `\ +
+ + +
+ + Not connected to chat +
+ +
`; + +const insert_jsmarkup = () => { + if (document.getElementById("info__title") === null) { + const parent = document.getElementById("info"); + parent.insertAdjacentHTML("beforeend", jsmarkup_info_title); + } + if (document.getElementById("chat-messages") === null) { + const parent = document.getElementById("chat__messages"); + parent.insertAdjacentHTML("beforeend", jsmarkup_chat_messages); + } + if (document.getElementById("chat-form") === null) { + const parent = document.getElementById("chat__form"); + parent.insertAdjacentHTML("beforeend", jsmarkup_chat_form); + } +} + +insert_jsmarkup(); + +/* create websocket */ +const info_title = document.getElementById("info__title"); +const chat_messages_parent = document.getElementById("chat__messages"); +const chat_messages = document.getElementById("chat-messages"); +const on_websocket_message = (event) => { + const receipt = JSON.parse(event.data); + switch (receipt.type) { + case "error": + console.log("server sent error via websocket", receipt); + break; + + case "init": + console.log("init", receipt); + chat_form_nonce.value = receipt.nonce; + info_title.innerText = receipt.title; + break; + + case "title": + console.log("title", receipt); + info_title.innerText = receipt.title; + break; + + case "ack": + console.log("ack", receipt); + if (chat_form_nonce.value === receipt.nonce) { + chat_form_message.value = ""; + } else { + console.log("nonce does not match ack", chat_form_nonce, receipt); + } + chat_form_submit.disabled = false; + chat_form_nonce.value = receipt.next; + break; + + case "chat": + const chat_message = document.createElement("li"); + chat_message.classList.add("chat-message"); + + const chat_message_name = document.createElement("span"); + chat_message_name.classList.add("chat-message__name"); + chat_message_name.innerText = receipt.name; + chat_message_name.style.color = receipt.color + + const chat_message_text = document.createElement("span"); + chat_message_text.classList.add("chat-message__text"); + chat_message_text.innerText = receipt.text; + + chat_message.insertAdjacentElement("beforeend", chat_message_name); + chat_message.insertAdjacentHTML("beforeend", ": "); + chat_message.insertAdjacentElement("beforeend", chat_message_text); + + chat_messages.insertAdjacentElement("beforeend", chat_message); + chat_messages_parent.scrollTo({ + left: 0, + top: chat_messages_parent.scrollTopMax, + behavior: "smooth", + }); + + console.log("chat", receipt); + break; + + default: + console.log("incomprehensible websocket message", message); + } +}; +const chat_live_ball = document.getElementById("chat-live__ball"); +const chat_live_status = document.getElementById("chat-live__status"); +let ws; +let websocket_backoff = 2000; // 2 seconds +const connect_websocket = () => { + if (ws !== undefined && (ws.readyState === ws.CONNECTING || ws.readyState === ws.OPEN)) { + console.log("refusing to open another websocket"); + return; + } + chat_live_ball.style.borderColor = "gold"; + chat_live_status.innerText = "Connecting to chat..."; + ws = new WebSocket(`ws://${document.domain}:${location.port}/live`); + ws.addEventListener("open", (event) => { + chat_form_submit.disabled = false; + chat_live_ball.style.borderColor = "green"; + chat_live_status.innerText = "Connected to chat"; + websocket_backoff = 2000; // 2 seconds + }); + ws.addEventListener("close", (event) => { + chat_form_submit.disabled = true; + chat_live_ball.style.borderColor = "maroon"; + chat_live_status.innerText = "Disconnected from chat"; + setTimeout(connect_websocket, websocket_backoff); + websocket_backoff = Math.min(32000, websocket_backoff * 2); + console.log("websocket closed", event); + }); + ws.addEventListener("error", (event) => { + chat_form_submit.disabled = true; + chat_live_ball.style.borderColor = "maroon"; + chat_live_status.innerText = "Error connecting to chat"; + console.log("websocket error", event); + }); + ws.addEventListener("message", on_websocket_message); +} + +connect_websocket(); + +/* override js-only chat form */ +const chat_form = document.getElementById("chat-form"); +const chat_form_nonce = document.getElementById("chat-form__nonce"); +const chat_form_message = document.getElementById("chat-form__message"); +const chat_form_submit = document.getElementById("chat-form__submit"); +chat_form.addEventListener("submit", (event) => { + event.preventDefault(); + const payload = {message: chat_form_message.value, nonce: chat_form_nonce.value}; + chat_form_submit.disabled = true; + ws.send(JSON.stringify(payload)); +}); diff --git a/static/style.css b/anonstream/static/style.css similarity index 52% rename from static/style.css rename to anonstream/static/style.css index 434e2ad..1c9de7e 100644 --- a/static/style.css +++ b/anonstream/static/style.css @@ -16,6 +16,7 @@ --pure-video-height: calc(100vw * var(--aspect-y) / var(--aspect-x)); --video-height: max(144px, min(75vh, var(--pure-video-height))); } + body { margin: 0; background-color: var(--main-bg-color); @@ -23,7 +24,7 @@ body { font-family: sans-serif; height: 100vh; display: grid; - grid-auto-rows: var(--video-height) min-content min-content 1fr min-content; + grid-auto-rows: var(--video-height) auto min-content 1fr auto; grid-template-areas: "stream" "toggle" @@ -34,32 +35,118 @@ body { a { color: #42a5d7; } + #stream { background: black; width: 100%; height: var(--video-height); grid-area: stream; } + #info { - display: none; border-top: var(--main-border); box-sizing: border-box; - padding: 0.5ch 1ch; - font-size: 18pt; + padding: 0.75ch 1ch; overflow-y: scroll; grid-area: info; } +#info__title { + font-size: 18pt; +} + #chat { - display: none; + display: grid; + grid-auto-rows: auto 1fr auto; background-color: var(--chat-bg-color); border-top: var(--chat-border); + border-bottom: var(--chat-border); grid-area: chat; + height: 50vh; + min-height: 24ch; } #chat__header { text-align: center; padding: 1ch 0; + margin-bottom: 1ch; border-bottom: var(--chat-border); } +#chat-form { + display: grid; + grid-template: auto 2rem / auto 8ch; + grid-gap: 0.75ch; + margin: 1ch; +} +#chat-form__submit { + grid-column: 2 / span 1; +} +#chat-form__message { + grid-column: 1 / span 2; + background-color: #434347; + border-radius: 4px; + border: 2px solid transparent; + transition: 0.25s; + max-height: 16ch; + min-height: 2.25ch; + padding: 1.5ch; + color: #c3c3c7; + resize: vertical; +} +#chat-form__message:not(:focus):hover { + border-color: #737377; +} +#chat-form__message:focus { + background-color: black; + border-color: #3584e4; +} +#chat__messages { + margin: 0 1ch; + overflow-y: auto; + position: relative; +} +#chat-messages { + list-style: none; + padding: 0; + margin: 0; + width: 100%; + max-height: 100%; + position: absolute; + bottom: 0; + font-size: 11pt; +} +.chat-message { + padding: 0.5ch 0.75ch ; + width: 100%; + box-sizing: border-box; + border-radius: 4px; +} +.chat-message:hover { + background-color: #434347; +} +.chat-message__name { + font-weight: bold; + color: attr("data-color"); + cursor: default; +} +.chat-message__text { + overflow-wrap: anywhere; +} +#chat-live { + font-size: 9pt; + line-height: 2rem; +} +#chat-live__ball { + border: 4px solid maroon; + border-radius: 4px; + display: inline-block; + margin-right: 2px; + animation: 3s infinite glow; +} +@keyframes glow { + 0% {filter: brightness(100%)} + 50% {filter: brightness(150%)} + 100% {filter: brightness(100%)} +} + #toggle { grid-area: toggle; border-top: var(--main-border); @@ -72,19 +159,26 @@ a { font-variant: all-small-caps; text-decoration: none; color: inherit; - background-color: #74479c; + background-color: #753ba8; border: 2px outset #8a2be2; } + footer { grid-area: footer; text-align: center; - padding: 0.5ch; - border-top: var(--main-border); + padding: 0.75ch; + background: linear-gradient(#38383d, #1d1d20 16%); font-size: 9pt; } -#info:target, #both:target > #info, + +#info, #chat { + display: none; +} +#info:target, #both:target > #info { + display: block; +} #chat:target, #both:target > #chat { - display: initial; + display: grid; } #info:target ~ #toggle > [href="#info"], #chat:target ~ #toggle > [href="#chat"], @@ -92,12 +186,13 @@ footer { background-color: #9943e9; border-style: inset; } + @media (min-width: 720px) { :root { --pure-video-height: calc((100vw - var(--chat-width) - var(--border-width)) * var(--aspect-y) / var(--aspect-x)); } body { - grid-auto-rows: var(--video-height) 1fr min-content; + grid-auto-rows: var(--video-height) 1fr auto; grid-auto-columns: 1fr 320px; grid-template-areas: "stream chat" @@ -111,11 +206,11 @@ footer { height: var(--video-height); } #info { - display: initial; + display: block; } #chat { - display: initial; - border-top: none; + display: grid; + border: none; border-left: var(--chat-border); min-height: 100vh; } diff --git a/anonstream/templates/home.html b/anonstream/templates/home.html new file mode 100644 index 0000000..28f6c2c --- /dev/null +++ b/anonstream/templates/home.html @@ -0,0 +1,29 @@ + + + + + + + + +
+ +
+ + + + + + diff --git a/anonstream/templates/info.html b/anonstream/templates/info.html new file mode 100644 index 0000000..3f6f976 --- /dev/null +++ b/anonstream/templates/info.html @@ -0,0 +1,10 @@ + + + + + + + +
{{ title }}
+ + diff --git a/anonstream/utils/chat.py b/anonstream/utils/chat.py new file mode 100644 index 0000000..af7cee1 --- /dev/null +++ b/anonstream/utils/chat.py @@ -0,0 +1,11 @@ +import base64 +import hashlib +import secrets + +def generate_nonce(): + return secrets.token_urlsafe(16) + +def generate_message_id(secret, nonce): + parts = secret + b'message-id\0' + nonce.encode() + digest = hashlib.sha256(parts).digest() + return base64.urlsafe_b64encode(digest)[:22].decode() diff --git a/anonstream/utils/token.py b/anonstream/utils/token.py new file mode 100644 index 0000000..67fed0c --- /dev/null +++ b/anonstream/utils/token.py @@ -0,0 +1,4 @@ +import secrets + +def generate_token(): + return secrets.token_hex(16) diff --git a/anonstream/utils/websocket.py b/anonstream/utils/websocket.py new file mode 100644 index 0000000..7c5ebb0 --- /dev/null +++ b/anonstream/utils/websocket.py @@ -0,0 +1,19 @@ +from anonstream.utils.chat import generate_message_id + +def parse(message_ids, secret, receipt): + if not isinstance(receipt, dict): + return None, 'not a json object' + + message = receipt.get('message') + if not isinstance(message, str): + return None, 'malformed chat message' + + nonce = receipt.get('nonce') + if not isinstance(nonce, str): + return None, 'malformed nonce' + + message_id = generate_message_id(secret, nonce) + if message_id in message_ids: + return None, 'nonce already used' + + return (message, nonce, message_id), None diff --git a/anonstream/websocket.py b/anonstream/websocket.py new file mode 100644 index 0000000..411c3c4 --- /dev/null +++ b/anonstream/websocket.py @@ -0,0 +1,50 @@ +import asyncio + +from quart import websocket + +from anonstream.chat import add_chat_message +from anonstream.utils.chat import generate_nonce +from anonstream.utils.websocket import parse + +async def websocket_outbound(queue): + payload = { + 'type': 'init', + 'nonce': generate_nonce(), + 'title': 'Stream title', + 'uptime': None, + 'chat': [], + } + await websocket.send_json(payload) + while True: + payload = await queue.get() + await websocket.send_json(payload) + +async def websocket_inbound(secret, connected_websockets, chat, token): + while True: + receipt = await websocket.receive_json() + receipt, error = parse(chat.keys(), secret, receipt) + if error is not None: + payload = { + 'type': 'error', + 'because': error, + } + else: + text, nonce, message_id = receipt + add_chat_message(chat, message_id, token, text) + payload = { + 'type': 'ack', + 'nonce': nonce, + 'next': generate_nonce(), + } + queue = connected_websockets[token] + await queue.put(payload) + + if error is None: + payload = { + 'type': 'chat', + 'color': '#c7007f', + 'name': 'Anonymous', + 'text': text, + } + for queue in connected_websockets.values(): + await queue.put(payload) diff --git a/anonstream/wrappers.py b/anonstream/wrappers.py new file mode 100644 index 0000000..9c46f68 --- /dev/null +++ b/anonstream/wrappers.py @@ -0,0 +1,38 @@ +from functools import wraps + +from quart import current_app +from werkzeug.security import check_password_hash + +from anonstream.utils.token import generate_token + +def check_auth(context): + auth = context.authorization + return ( + auth is not None + and auth.type == "basic" + and auth.username == current_app.config["AUTH_USERNAME"] + and check_password_hash(auth.password, current_app.config["AUTH_PWHASH"]) + ) + +def auth_required(f): + @wraps(f) + async def wrapper(*args, **kwargs): + if check_auth(request): + return await func(*args, **kwargs) + else: + abort(401) + + return wrapper + +def with_token_from(context): + def with_token_from_context(f): + @wraps(f) + async def wrapper(*args, **kwargs): + if check_auth(context): + token = current_app.config['AUTH_TOKEN'] + else: + token = context.args.get('token') or generate_token() + return await f(token, *args, **kwargs) + + return wrapper + return with_token_from_context diff --git a/app.py b/app.py index fa3a030..9da3c1c 100644 --- a/app.py +++ b/app.py @@ -1,10 +1,9 @@ -from quart import Quart, render_template +import asyncio +import anonstream -app = Quart(__name__) - -@app.route('/') -async def home(): - return await render_template('home.html') +async def main(): + app = await anonstream.create_app() + await app.run_task(port=5051, debug=True) if __name__ == '__main__': - app.run(port=5051) + asyncio.run(main()) diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..f511f85 --- /dev/null +++ b/config.toml @@ -0,0 +1,2 @@ +secret = "test" +auth_username = "broadcaster" diff --git a/templates/home.html b/templates/home.html deleted file mode 100644 index 3e62810..0000000 --- a/templates/home.html +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - -
- Stream title -
- - - - -