Project structure, chat markup/style, websockets
このコミットが含まれているのは:
コミット
71586420b6
|
@ -0,0 +1 @@
|
|||
__pycache__/
|
|
@ -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
|
|
@ -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,
|
||||
}
|
|
@ -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)
|
|
@ -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", ": ");
|
||||
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;
|
||||
}
|
|
@ -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 — <a href="#" target="_blank">source</a></footer>
|
||||
<script src="/static/anonstream.js" type="text/javascript"></script>
|
||||
</body>
|
||||
</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>
|
|
@ -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()
|
|
@ -0,0 +1,4 @@
|
|||
import secrets
|
||||
|
||||
def generate_token():
|
||||
return secrets.token_hex(16)
|
|
@ -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
|
|
@ -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)
|
|
@ -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
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())
|
||||
|
|
|
@ -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 — <a href="#" target="_blank">source</a></footer>
|
||||
</body>
|
||||
</html>
|
読み込み中…
新しいイシューから参照