Initial noscript markup for chat form & stream info

Use with-user decorator on routes (instead of with-token)
このコミットが含まれているのは:
n9k 2022-02-14 10:16:09 +00:00
コミット 885c10b5de
16個のファイルの変更376行の追加82行の削除

ファイルの表示

@ -5,7 +5,7 @@ from collections import OrderedDict
from quart import Quart
from werkzeug.security import generate_password_hash
from anonstream.utils.token import generate_token
from anonstream.utils.users import generate_token
from anonstream.segments import DirectoryCache
async def create_app():
@ -22,8 +22,11 @@ async def create_app():
app.config['AUTH_USERNAME'] = config['auth_username']
app.config['AUTH_PWHASH'] = auth_pwhash
app.config['AUTH_TOKEN'] = generate_token()
app.config['DEFAULT_HOST_NAME'] = config['default_host_name']
app.config['DEFAULT_ANON_NAME'] = config['default_anon_name']
app.chat = OrderedDict()
app.websockets = {}
app.users = {}
app.websockets = set()
app.segments_directory_cache = DirectoryCache(config['segments_dir'])
async with app.app_context():

ファイルの表示

@ -2,21 +2,26 @@ import asyncio
from quart import current_app, request, render_template, make_response, redirect, websocket
from anonstream.segments import CatSegments
from anonstream.wrappers import with_token_from, auth_required
from anonstream.stream import get_stream_title
from anonstream.segments import CatSegments, Offline
from anonstream.users import get_default_name
from anonstream.wrappers import with_user_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)
@with_user_from(request)
async def home(user):
return await render_template('home.html', user=user)
@current_app.route('/stream.mp4')
@with_token_from(request)
async def stream(token):
@with_user_from(request)
async def stream(user):
try:
cat_segments = CatSegments(current_app.segments_directory_cache, token)
except ValueError:
cat_segments = CatSegments(
directory_cache=current_app.segments_directory_cache,
token=user['token']
)
except Offline:
return 'offline', 404
response = await make_response(cat_segments.stream())
response.headers['Content-Type'] = 'video/mp4'
@ -29,19 +34,43 @@ async def login():
return redirect('/')
@current_app.websocket('/live')
@with_token_from(websocket)
async def live(token):
@with_user_from(websocket)
async def live(user):
queue = asyncio.Queue()
current_app.websockets[token] = queue
current_app.websockets.add(queue)
producer = websocket_outbound(queue)
consumer = websocket_inbound(
queue=queue,
connected_websockets=current_app.websockets,
token=token,
token=user['token'],
secret=current_app.config['SECRET_KEY'],
chat=current_app.chat,
)
try:
await asyncio.gather(producer, consumer)
finally:
current_app.websockets.pop(token)
current_app.websockets.remove(queue)
@current_app.route('/info.html')
@with_user_from(request)
async def nojs_info(user):
return await render_template(
'nojs_info.html',
user=user,
title=get_stream_title(),
)
@current_app.route('/chat/messages.html')
@with_user_from(request)
async def nojs_chat(user):
return await render_template('nojs_chat.html', user=user)
@current_app.route('/chat/form.html')
@with_user_from(request)
async def nojs_form(user):
return await render_template(
'nojs_form.html',
user=user,
default_name=get_default_name(user),
)

ファイルの表示

@ -8,8 +8,11 @@ import aiofiles
RE_SEGMENT = re.compile(r'^(?P<index>\d+)\.ts$')
class Offline(Exception):
pass
class DirectoryCache:
def __init__(self, directory, ttl=0.5):
def __init__(self, directory, ttl=1.0):
self.directory = directory
self.ttl = ttl
self.expires = None
@ -41,7 +44,10 @@ class CatSegments:
def __init__(self, directory_cache, token):
self.directory_cache = directory_cache
self.token = token
self.index = max(self.directory_cache.segments())
try:
self.index = max(self.directory_cache.segments())
except ValueError: # max of empty sequence, i.e. there are no segments
raise Offline
async def stream(self):
while True:

ファイルの表示

@ -1,27 +1,35 @@
/* token */
const token = document.querySelector("body").dataset.token;
/* 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_info = '<div id="info_js"></div>';
const jsmarkup_info_title = '<header id="info_js__title" data-js="true"></header>';
const jsmarkup_chat_messages = '<ul id="chat-messages_js" 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>
<form id="chat-form_js" data-js="true" action="/chat" method="post">
<input id="chat-form_js__nonce" type="hidden" name="nonce" value="">
<textarea id="chat-form_js__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>
<input id="chat-form_js__submit" type="submit" value="Chat" disabled>
</form>`;
const insert_jsmarkup = () => {
if (document.getElementById("info__title") === null) {
if (document.getElementById("info_js") === null) {
const parent = document.getElementById("info");
parent.insertAdjacentHTML("beforeend", jsmarkup_info);
}
if (document.getElementById("info_js__title") === null) {
const parent = document.getElementById("info_js");
parent.insertAdjacentHTML("beforeend", jsmarkup_info_title);
}
if (document.getElementById("chat-messages") === null) {
if (document.getElementById("chat-messages_js") === null) {
const parent = document.getElementById("chat__messages");
parent.insertAdjacentHTML("beforeend", jsmarkup_chat_messages);
}
if (document.getElementById("chat-form") === null) {
if (document.getElementById("chat-form_js") === null) {
const parent = document.getElementById("chat__form");
parent.insertAdjacentHTML("beforeend", jsmarkup_chat_form);
}
@ -30,9 +38,9 @@ const insert_jsmarkup = () => {
insert_jsmarkup();
/* create websocket */
const info_title = document.getElementById("info__title");
const info_title = document.getElementById("info_js__title");
const chat_messages_parent = document.getElementById("chat__messages");
const chat_messages = document.getElementById("chat-messages");
const chat_messages = document.getElementById("chat-messages_js");
const on_websocket_message = (event) => {
const receipt = JSON.parse(event.data);
switch (receipt.type) {
@ -69,7 +77,8 @@ const on_websocket_message = (event) => {
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
//chat_message_name.dataset.color = receipt.color; // not working in any browser
chat_message_name.style.color = receipt.color;
const chat_message_text = document.createElement("span");
chat_message_text.classList.add("chat-message__text");
@ -104,7 +113,7 @@ const connect_websocket = () => {
}
chat_live_ball.style.borderColor = "gold";
chat_live_status.innerText = "Connecting to chat...";
ws = new WebSocket(`ws://${document.domain}:${location.port}/live`);
ws = new WebSocket(`ws://${document.domain}:${location.port}/live?token=${encodeURIComponent(token)}`);
ws.addEventListener("open", (event) => {
chat_form_submit.disabled = false;
chat_live_ball.style.borderColor = "green";
@ -134,10 +143,10 @@ const connect_websocket = () => {
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");
const chat_form = document.getElementById("chat-form_js");
const chat_form_nonce = document.getElementById("chat-form_js__nonce");
const chat_form_message = document.getElementById("chat-form_js__message");
const chat_form_submit = document.getElementById("chat-form_js__submit");
chat_form.addEventListener("submit", (event) => {
event.preventDefault();
const payload = {message: chat_form_message.value, nonce: chat_form_nonce.value};

ファイルの表示

@ -15,6 +15,9 @@
--pure-video-height: calc(100vw * var(--aspect-y) / var(--aspect-x));
--video-height: max(144px, min(75vh, var(--pure-video-height)));
--button-height: 2rem;
--nojs-info-height: 17ch;
}
body {
@ -35,6 +38,15 @@ body {
a {
color: #42a5d7;
}
iframe {
width: 100%;
border: 1px solid red;
box-sizing: border-box;
}
noscript {
display: grid;
height: 100%;
}
#stream {
background: black;
@ -45,14 +57,18 @@ a {
#info {
border-top: var(--main-border);
box-sizing: border-box;
padding: 0.75ch 1ch;
overflow-y: scroll;
grid-area: info;
}
#info__title {
#info_js {
overflow-y: auto;
margin: 1ch 1.5ch;
}
#info_js__title {
font-size: 18pt;
}
#info_nojs {
height: var(--nojs-info-height);
}
#chat {
display: grid;
@ -70,51 +86,57 @@ a {
margin-bottom: 1ch;
border-bottom: var(--chat-border);
}
#chat-form {
#chat-form_js {
display: grid;
grid-template: auto 2rem / auto 8ch;
grid-template: auto var(--button-height) / auto 8ch;
grid-gap: 0.75ch;
margin: 1ch;
}
#chat-form__submit {
#chat-form_js__submit {
grid-column: 2 / span 1;
}
#chat-form__message {
#chat-form_js__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;
min-height: 1.75ch;
padding: 1.5ch;
color: #c3c3c7;
resize: vertical;
}
#chat-form__message:not(:focus):hover {
#chat-form_js__message:not(:focus):hover {
border-color: #737377;
}
#chat-form__message:focus {
#chat-form_js__message:focus {
background-color: black;
border-color: #3584e4;
}
#chat-form_nojs {
height: 13ch;
}
#chat__messages {
margin: 0 1ch;
overflow-y: auto;
position: relative;
}
#chat-messages {
#chat-messages_js {
list-style: none;
padding: 0;
margin: 0;
padding: 0 1ch;
width: 100%;
box-sizing: border-box;
max-height: 100%;
position: absolute;
bottom: 0;
font-size: 11pt;
}
#chat-messages_nojs {
height: 100%;
}
.chat-message {
padding: 0.5ch 0.75ch ;
padding: 0.5ch 0.75ch;
width: 100%;
box-sizing: border-box;
border-radius: 4px;
@ -129,10 +151,11 @@ a {
}
.chat-message__text {
overflow-wrap: anywhere;
line-height: 1.3125;
}
#chat-live {
font-size: 9pt;
line-height: 2rem;
line-height: var(--button-height);
}
#chat-live__ball {
border: 4px solid maroon;
@ -186,6 +209,9 @@ footer {
background-color: #3065a6;
border-style: inset;
}
#both:target #info_nojs {
height: 9ch;
}
@media (min-width: 720px) {
:root {
@ -214,4 +240,7 @@ footer {
border-left: var(--chat-border);
min-height: 100vh;
}
#both:target #info_nojs {
height: var(--nojs-info-height);
}
}

5
anonstream/stream.py ノーマルファイル
ファイルの表示

@ -0,0 +1,5 @@
def get_stream_title():
return 'Stream title'
def get_stream_uptime():
return None

ファイルの表示

@ -4,18 +4,18 @@
<meta charset="utf-8">
<link rel="stylesheet" href="/static/style.css" type="text/css">
</head>
<body id="both" data-token="{{ token }}">
<video id="stream" src="/stream.mp4" controls></video>
<body id="both" data-token="{{ user.token }}">
<video id="stream" src="{{ url_for('stream', token=user.token) }}" controls></video>
<article id="info">
<noscript><iframe id="info_js" data-js="false"></iframe></noscript>
<noscript><iframe id="info_nojs" src="{{ url_for('nojs_info', token=user.token) }}" 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>
<noscript><iframe id="chat-messages_nojs" src="{{ url_for('nojs_chat', token=user.token) }}" data-js="false"></iframe></noscript>
</article>
<section id="chat__form">
<noscript><iframe id="chat-form_nojs" data-js="false"></iframe></noscript>
<noscript><iframe id="chat-form_nojs" src="{{ url_for('nojs_form', token=user.token, _anchor='notice') }}" data-js="false"></iframe></noscript>
</section>
</aside>
<nav id="toggle">

ファイルの表示

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

172
anonstream/templates/nojs_form.html ノーマルファイル
ファイルの表示

@ -0,0 +1,172 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
:root {
--padding: 1ch;
}
body {
margin: 0;
height: 13ch;
color: white;
}
a {
color: #42a5d7;
}
label {
cursor: pointer;
}
.tripcode {
padding: 0 4px;
border-radius: 7px;
font-family: monospace;
align-self: center;
}
#notice {
padding: var(--margin);
text-align: center;
color: white;
text-decoration: none;
height: 100%;
box-sizing: border-box;
align-items: center;
}
#notice h1 {
font-size: 18pt;
margin: 0;
}
#chat-form, #appearance-form {
padding: var(--padding);
height: 100%;
box-sizing: border-box;
grid-gap: 0.75ch;
}
#chat-form {
display: grid;
grid: auto 2rem / auto 8ch;
grid-gap: 0.75ch;
}
#chat-form__message {
resize: none;
grid-column: 1 / span 2;
background-color: #434347;
border-radius: 4px;
border: 2px solid transparent;
transition: 0.25s;
padding: 1.5ch;
color: #c3c3c7;
}
#chat-form__message:not(:focus):hover {
border-color: #737377;
}
#chat-form__message:focus {
background-color: black;
border-color: #3584e4;
}
#appearance-form {
grid-auto-rows: 1fr 1fr 2rem;
grid-auto-columns: min-content 1fr min-content;
}
#appearance-form__label-password,
#appearance-form__password,
#password-column,
#hide-password {
grid-row: 2;
}
#appearance-form__buttons {
grid-column: 1 / span 3;
display: grid;
grid-template-columns: auto 8ch;
}
#password-toggle,
#appearance-form__password {
display: none;
}
#hide-password {
visibility: hidden;
}
#password-toggle:checked + #cleared-toggle:not(:checked) ~ #appearance-form__password {
display: inline;
}
#password-toggle:checked + #cleared-toggle:not(:checked) ~ #hide-password {
visibility: visible;
}
#password-toggle:checked + #cleared-toggle:not(:checked) + #password-column {
display: none;
}
#cleared-toggle,
#cleared,
#hide-cleared {
display: none;
}
#cleared-toggle:checked + #password-column > #cleared,
#cleared-toggle:checked + #password-column > #hide-cleared {
display: inline;
}
#cleared-toggle:checked + #password-column > #tripcode,
#cleared-toggle:checked + #password-column > #show-cleared {
display: none;
}
#notice, #appearance-form {
display: none;
}
#anchor:target > #chat-form {
display: none;
}
#anchor:target > #appearance-form {
display: grid;
}
#notice:target {
display: grid;
}
#notice:target + #chat-form,
#notice:target ~ #appearance-form {
display: none;
}
</style>
</head>
<body id="anchor">
<a href="#chat-form" id="notice">
<header><h1>No notice</h1></header>
<small>Click to dismiss</small>
</a>
</article>
<form id="chat-form" action="/chat/message">
<input type="hidden" name="token" value="{{ user.token }}">
<textarea id="chat-form__message" name="text" placeholder="Send a message..." required rows="1"></textarea>
<div id="chat-form__exit"><a href="#anchor">Settings</a></div>
<input id="chat-form__submit" type="submit" value="Chat">
</form>
<form id="appearance-form" action="/chat/appearance">
<input type="hidden" name="token" value="{{ user.token }}">
<label for="appearance-form__name">Name:</label>
<input id="appearance-form__name" name="name" value="{{ user.name or '' }}" placeholder="{{ user.name or default_name }}">
<label id="appearance-form__label-password" for="appearance-form__password">Tripcode:</label>
<input id="password-toggle" name="set-tripcode" type="checkbox">
<input id="cleared-toggle" name="clear-tripcode" type="checkbox">
<div id="password-column">
{% if user.tripcode == none %}
<label class="show-password tripcode" for="password-toggle">(no tripcode)</label>
{% else %}
<label id="tripcode" for="password-toggle" class="show-password tripcode" style="background-color:{{ user.tripcode.background }};color:{{ user.tripcode.foreground }};">{{ user.tripcode.digest }}</label>
<label id="show-cleared" for="cleared-toggle">&cross;</label>
<div id="cleared" class="tripcode">(cleared)</div>
<label id="hide-cleared" for="cleared-toggle">undo</label>
{% endif %}
</div>
<input id="appearance-form__password" name="password" type="password"></div>
<label id="hide-password" for="password-toggle">&cross;</label>
<div id="appearance-form__buttons">
<div><a href="#chat-form">Return to chat</a></div>
<input type="submit" value="Update">
</div>
</form>
</body>
</html>

19
anonstream/templates/nojs_info.html ノーマルファイル
ファイルの表示

@ -0,0 +1,19 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
body {
overflow-y: scroll;
margin: 1ch 1.5ch;
}
#title {
color: white;
font-size: 18pt;
}
</style>
</head>
<body>
<header id="title">{{ title }}</header>
</body>
</html>

9
anonstream/users.py ノーマルファイル
ファイルの表示

@ -0,0 +1,9 @@
from quart import current_app
def get_default_name(user):
return (
current_app.config['DEFAULT_HOST_NAME']
if user['broadcaster'] else
current_app.config['DEFAULT_ANON_NAME']
)

ファイルの表示

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

16
anonstream/utils/users.py ノーマルファイル
ファイルの表示

@ -0,0 +1,16 @@
import secrets
def generate_token():
return secrets.token_hex(16)
def generate_user(token, broadcaster, timestamp):
return {
'token': token,
'broadcaster': broadcaster,
'name': None,
'tripcode': None,
'seen': {
'first': timestamp,
'last': timestamp,
},
}

ファイルの表示

@ -2,6 +2,7 @@ import asyncio
from quart import websocket
from anonstream.stream import get_stream_title, get_stream_uptime
from anonstream.chat import add_chat_message
from anonstream.utils.chat import generate_nonce
from anonstream.utils.websocket import parse
@ -10,8 +11,8 @@ async def websocket_outbound(queue):
payload = {
'type': 'init',
'nonce': generate_nonce(),
'title': 'Stream title',
'uptime': None,
'title': get_stream_title(),
'uptime': get_stream_uptime(),
'chat': [],
}
await websocket.send_json(payload)
@ -19,7 +20,7 @@ async def websocket_outbound(queue):
payload = await queue.get()
await websocket.send_json(payload)
async def websocket_inbound(connected_websockets, token, secret, chat):
async def websocket_inbound(queue, connected_websockets, token, secret, chat):
while True:
receipt = await websocket.receive_json()
receipt, error = parse(chat.keys(), secret, receipt)
@ -36,7 +37,6 @@ async def websocket_inbound(connected_websockets, token, secret, chat):
'nonce': nonce,
'next': generate_nonce(),
}
queue = connected_websockets[token]
await queue.put(payload)
if error is None:
@ -46,5 +46,5 @@ async def websocket_inbound(connected_websockets, token, secret, chat):
'name': 'Anonymous',
'text': text,
}
for queue in connected_websockets.values():
await queue.put(payload)
for other_queue in connected_websockets:
await other_queue.put(payload)

ファイルの表示

@ -1,9 +1,10 @@
import time
from functools import wraps
from quart import current_app, request, abort, make_response
from werkzeug.security import check_password_hash
from anonstream.utils.token import generate_token
from anonstream.utils.users import generate_token, generate_user
def check_auth(context):
auth = context.authorization
@ -31,15 +32,23 @@ def auth_required(f):
return wrapper
def with_token_from(context):
def with_token_from_context(f):
def with_user_from(context):
def with_user_from_context(f):
@wraps(f)
async def wrapper(*args, **kwargs):
if check_auth(context):
broadcaster = check_auth(context)
if broadcaster:
token = current_app.config['AUTH_TOKEN']
else:
token = context.args.get('token') or generate_token()
return await f(token, *args, **kwargs)
timestamp = int(time.time())
user = current_app.users.get(token)
if user is not None:
user['seen']['last'] = timestamp
else:
user = generate_user(token, broadcaster, timestamp)
return await f(user, *args, **kwargs)
return wrapper
return with_token_from_context
return with_user_from_context

ファイルの表示

@ -1,3 +1,5 @@
secret_key = "test"
auth_username = "broadcaster"
segments_dir = "stream/"
default_host_name = "Broadcaster"
default_anon_name = "Anonymous"