Project structure, chat markup/style, websockets

このコミットが含まれているのは:
n9k 2022-02-13 04:00:10 +00:00
コミット 71586420b6
17個のファイルの変更503行の追加43行の削除

1
.gitignore vendored ノーマルファイル
ファイルの表示

@ -0,0 +1 @@
__pycache__/

ファイルの表示

30
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

17
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,
}

34
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)

143
anonstream/static/anonstream.js ノーマルファイル
ファイルの表示

@ -0,0 +1,143 @@
/* insert js-only markup */
const jsmarkup_info_title = '<header id="info__title" data-js="true"></header>';
const jsmarkup_chat_messages = '<ul id="chat-messages" data-js="true"></ul>';
const jsmarkup_chat_form = `\
<form id="chat-form" data-js="true" action="/chat" method="post">
<input id="chat-form__nonce" type="hidden" name="nonce" value="">
<textarea id="chat-form__message" name="message" maxlength="512" required placeholder="Send a message..." rows="1"></textarea>
<div id="chat-live">
<span id="chat-live__ball"></span>
<span id="chat-live__status">Not connected to chat</span>
</div>
<input id="chat-form__submit" type="submit" value="Chat" disabled>
</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", ":&nbsp;");
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));
});

ファイルの表示

@ -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;
}

29
anonstream/templates/home.html ノーマルファイル
ファイルの表示

@ -0,0 +1,29 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="/static/style.css" type="text/css">
</head>
<body id="both">
<video id="stream" src="/stream.mp4" controls></video>
<article id="info">
<noscript><iframe id="info_js" data-js="false"></iframe></noscript>
</article>
<aside id="chat">
<header id="chat__header">Stream chat</header>
<article id="chat__messages">
<noscript><iframe id="chat-messages_nojs" data-js="false"></iframe></noscript>
</article>
<section id="chat__form">
<noscript><iframe id="chat-form_nojs" data-js="false"></iframe></noscript>
</section>
</aside>
<nav id="toggle">
<a href="#info">info</a>
<a href="#chat">chat</a>
<a href="#both">both</a>
</nav>
<footer>anonstream 1.0.0 &mdash; <a href="#" target="_blank">source</a></footer>
<script src="/static/anonstream.js" type="text/javascript"></script>
</body>
</html>

10
anonstream/templates/info.html ノーマルファイル
ファイルの表示

@ -0,0 +1,10 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>#title {font-size: 18pt;}</style>
</head>
<body>
<header id="title">{{ title }}</header>
</body>
</html>

11
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()

4
anonstream/utils/token.py ノーマルファイル
ファイルの表示

@ -0,0 +1,4 @@
import secrets
def generate_token():
return secrets.token_hex(16)

19
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

50
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)

38
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

13
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())

2
config.toml ノーマルファイル
ファイルの表示

@ -0,0 +1,2 @@
secret = "test"
auth_username = "broadcaster"

ファイルの表示

@ -1,22 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="/static/style.css" type="text/css">
</head>
<body id="both">
<video id="stream" src="/stream.mp4" controls></video>
<article id="info">
Stream title
</article>
<aside id="chat">
<header id="chat__header">Stream chat</header>
</aside>
<nav id="toggle">
<a href="#info">info</a>
<a href="#chat">chat</a>
<a href="#both">both</a>
</nav>
<footer>v1.0.0 &mdash; <a href="#" target="_blank">source</a></footer>
</body>
</html>