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 = `\
+ `;
+
+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 @@
+
+
+
+
+
+
+
+
+
+
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
-
-
-
-
-
-