Nojs chat, store all user names/colors in js, forget about inactive users

Project structure evolving a bit
このコミットが含まれているのは:
n9k 2022-02-16 09:55:30 +00:00
コミット e77862f4ff
22個のファイルの変更660行の追加264行の削除

ファイルの表示

@ -18,14 +18,25 @@ async def create_app():
print('Broadcaster password:', auth_password)
app = Quart('anonstream')
app.config['SECRET_KEY'] = config['secret_key'].encode()
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['names']['broadcaster']
app.config['DEFAULT_ANON_NAME'] = config['names']['anonymous']
app.config['LIMIT_NOTICES'] = config['limits']['notices']
app.chat = OrderedDict()
app.config.update({
'SECRET_KEY': config['secret_key'].encode(),
'AUTH_USERNAME': config['auth']['username'],
'AUTH_PWHASH': auth_pwhash,
'AUTH_TOKEN': generate_token(),
'DEFAULT_HOST_NAME': config['names']['broadcaster'],
'DEFAULT_ANON_NAME': config['names']['anonymous'],
'MAX_NOTICES': config['limits']['notices'],
'MAX_CHAT_STORAGE': config['limits']['chat_storage'],
'MAX_CHAT_SCROLLBACK': config['limits']['chat_scrollback'],
'USER_CHECKUP_PERIOD': config['ratelimits']['user_absence'],
'CAPTCHA_CHECKUP_PERIOD': config['ratelimits']['captcha_expiry'],
'THRESHOLD_IDLE': config['thresholds']['idle'],
'THRESHOLD_ABSENT': config['thresholds']['absent'],
})
assert app.config['THRESHOLD_ABSENT'] >= app.config['THRESHOLD_IDLE']
app.chat = {'messages': OrderedDict(), 'nonce_hashes': set()}
app.users = {}
app.websockets = set()
app.segments_directory_cache = DirectoryCache(config['stream']['segments_dir'])

ファイルの表示

@ -1,7 +1,11 @@
import time
from datetime import datetime
from quart import escape
from anonstream.users import users_for_websocket
from anonstream.utils.chat import generate_nonce_hash
class Rejected(Exception):
pass
@ -9,18 +13,31 @@ async def broadcast(websockets, payload):
for queue in websockets:
await queue.put(payload)
async def add_chat_message(chat, websockets, token, message_id, comment):
async def add_chat_message(chat, users, websockets, secret, user, nonce, comment):
# check message
nonce_hash = generate_nonce_hash(secret, nonce)
if nonce_hash in chat['nonce_hashes']:
raise Rejected('Discarded suspected duplicate message')
if len(comment) == 0:
raise Rejected('Message was empty')
# add message
dt = datetime.utcnow()
timestamp_ms = time.time_ns() // 1_000_000
timestamp = timestamp_ms // 1000
try:
last_message = next(reversed(chat['messages'].values()))
except StopIteration:
message_id = timestamp_ms
else:
if timestamp <= last_message['id']:
message_id = last_message['id'] + 1
dt = datetime.utcfromtimestamp(timestamp)
markup = escape(comment)
chat[message_id] = {
chat['messages'][message_id] = {
'id': message_id,
'token': token,
'timestamp': int(dt.timestamp()),
'nonce_hash': nonce_hash,
'token': user['token'],
'timestamp': timestamp,
'date': dt.strftime('%Y-%m-%d'),
'time_minutes': dt.strftime('%H:%M'),
'time_seconds': dt.strftime('%H:%M:%S'),
@ -28,13 +45,16 @@ async def add_chat_message(chat, websockets, token, message_id, comment):
'markup': markup,
}
# collect nonce hash
chat['nonce_hashes'].add(nonce_hash)
# broadcast message to websockets
await broadcast(
websockets,
payload={
'type': 'chat',
'color': '#c7007f',
'name': 'Anonymous',
'id': message_id,
'token_hash': user['token_hash'],
'markup': markup,
}
)

ファイルの表示

@ -1,121 +0,0 @@
import asyncio
from quart import current_app, request, render_template, make_response, redirect, websocket, url_for
from anonstream.stream import get_stream_title
from anonstream.segments import CatSegments, Offline
from anonstream.users import get_default_name, add_notice, pop_notice
from anonstream.wrappers import with_user_from, auth_required
from anonstream.websocket import websocket_outbound, websocket_inbound
from anonstream.chat import add_chat_message, Rejected
from anonstream.utils.chat import create_message, generate_nonce, NonceReuse
@current_app.route('/')
@with_user_from(request)
async def home(user):
return await render_template('home.html', user=user)
@current_app.route('/stream.mp4')
@with_user_from(request)
async def stream(user):
try:
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'
response.timeout = None
return response
@current_app.route('/login')
@auth_required
async def login():
return redirect('/')
@current_app.websocket('/live')
@with_user_from(websocket)
async def live(user):
queue = asyncio.Queue()
current_app.websockets.add(queue)
producer = websocket_outbound(queue)
consumer = websocket_inbound(
queue=queue,
connected_websockets=current_app.websockets,
token=user['token'],
secret=current_app.config['SECRET_KEY'],
chat=current_app.chat,
)
try:
await asyncio.gather(producer, consumer)
finally:
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):
notice_id = request.args.get('notice', type=int)
prefer_chat_form = request.args.get('landing') != 'appearance'
return await render_template(
'nojs_form.html',
user=user,
notice=pop_notice(user, notice_id),
prefer_chat_form=prefer_chat_form,
nonce=generate_nonce(),
default_name=get_default_name(user),
)
@current_app.post('/chat/message')
@with_user_from(request)
async def nojs_submit_message(user):
form = await request.form
comment = form.get('comment', '')
nonce = form.get('nonce', '')
try:
message_id, _, _ = create_message(
message_ids=current_app.chat.keys(),
secret=current_app.config['SECRET_KEY'],
nonce=nonce,
comment=comment,
)
except NonceReuse:
notice_id = add_notice(user, 'Discarded suspected duplicate message')
else:
try:
await add_chat_message(
current_app.chat,
current_app.websockets,
user['token'],
message_id,
comment
)
except Rejected as e:
notice, *_ = e.args
notice_id = add_notice(user, notice)
else:
notice_id = None
return redirect(url_for('nojs_form', token=user['token'], notice=notice_id))
@current_app.post('/chat/appearance')
@with_user_from(request)
async def nojs_submit_appearance(user):
pass

3
anonstream/routes/__init__.py ノーマルファイル
ファイルの表示

@ -0,0 +1,3 @@
import anonstream.routes.core
import anonstream.routes.websocket
import anonstream.routes.nojs

29
anonstream/routes/core.py ノーマルファイル
ファイルの表示

@ -0,0 +1,29 @@
from quart import current_app, request, render_template, redirect, url_for
from anonstream.segments import CatSegments, Offline
from anonstream.routes.wrappers import with_user_from, auth_required
@current_app.route('/')
@with_user_from(request)
async def home(user):
return await render_template('home.html', user=user)
@current_app.route('/stream.mp4')
@with_user_from(request)
async def stream(user):
try:
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'
response.timeout = None
return response
@current_app.route('/login')
@auth_required
async def login():
return redirect(url_for('home'))

71
anonstream/routes/nojs.py ノーマルファイル
ファイルの表示

@ -0,0 +1,71 @@
from quart import current_app, request, render_template, redirect, url_for
from anonstream.stream import get_stream_title
from anonstream.users import get_default_name, add_notice, pop_notice
from anonstream.chat import add_chat_message, Rejected
from anonstream.routes.wrappers import with_user_from
from anonstream.utils.chat import generate_nonce
@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,
users=current_app.users,
messages=current_app.chat['messages'].values(),
get_default_name=get_default_name,
)
@current_app.route('/chat/form.html')
@with_user_from(request)
async def nojs_form(user):
notice_id = request.args.get('notice', type=int)
prefer_chat_form = request.args.get('landing') != 'appearance'
return await render_template(
'nojs_form.html',
user=user,
notice=pop_notice(user, notice_id),
prefer_chat_form=prefer_chat_form,
nonce=generate_nonce(),
default_name=get_default_name(user),
)
@current_app.post('/chat/message')
@with_user_from(request)
async def nojs_submit_message(user):
form = await request.form
comment = form.get('comment', '')
nonce = form.get('nonce', '')
try:
await add_chat_message(
chat=current_app.chat,
users=current_app.users,
websockets=current_app.websockets,
secret=current_app.config['SECRET_KEY'],
user=user,
nonce=nonce,
comment=comment,
)
except Rejected as e:
notice, *_ = e.args
notice_id = add_notice(user, notice)
else:
notice_id = None
return redirect(url_for('nojs_form', token=user['token'], notice=notice_id))
@current_app.post('/chat/appearance')
@with_user_from(request)
async def nojs_submit_appearance(user):
pass

32
anonstream/routes/websocket.py ノーマルファイル
ファイルの表示

@ -0,0 +1,32 @@
import asyncio
from quart import current_app, websocket
from anonstream.websocket import websocket_outbound, websocket_inbound
from anonstream.routes.wrappers import with_user_from
@current_app.websocket('/live')
@with_user_from(websocket)
async def live(user):
queue = asyncio.Queue()
current_app.websockets.add(queue)
producer = websocket_outbound(
queue=queue,
messages=current_app.chat['messages'].values(),
users=current_app.users,
default_host_name=current_app.config['DEFAULT_HOST_NAME'],
default_anon_name=current_app.config['DEFAULT_ANON_NAME'],
)
consumer = websocket_inbound(
queue=queue,
chat=current_app.chat,
users=current_app.users,
connected_websockets=current_app.websockets,
user=user,
secret=current_app.config['SECRET_KEY'],
)
try:
await asyncio.gather(producer, consumer)
finally:
current_app.websockets.remove(queue)

93
anonstream/routes/wrappers.py ノーマルファイル
ファイルの表示

@ -0,0 +1,93 @@
import time
from functools import wraps
from quart import current_app, request, abort, make_response
from werkzeug.security import check_password_hash
from anonstream.users import sunset, user_for_websocket
from anonstream.websocket import broadcast
from anonstream.utils.users import generate_token, generate_user
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(current_app.config["AUTH_PWHASH"], auth.password)
)
def auth_required(f):
@wraps(f)
async def wrapper(*args, **kwargs):
if check_auth(request):
return await f(*args, **kwargs)
hint = 'The broadcaster should log in with the credentials printed ' \
'in their terminal.'
body = (
f'<p>{hint}</p>'
if request.authorization is None else
'<p>Wrong username or password. Refresh the page to try again.</p>'
f'<p>{hint}</p>'
)
return body, 401, {'WWW-Authenticate': 'Basic'}
return wrapper
def with_user_from(context):
def with_user_from_context(f):
@wraps(f)
async def wrapper(*args, **kwargs):
timestamp = int(time.time())
# Check if broadcaster
broadcaster = check_auth(context)
if broadcaster:
token = current_app.config['AUTH_TOKEN']
else:
token = context.args.get('token') or context.cookies.get('token') or generate_token()
# Remove non-visible absent users
token_hashes = sunset(
messages=current_app.chat['messages'].values(),
users=current_app.users,
)
if len(token_hashes) > 0:
await broadcast(
current_app.websockets,
payload={
'type': 'rem-users',
'token_hashes': token_hashes,
}
)
# Update / create user
user = current_app.users.get(token)
if user is not None:
user['seen']['last'] = timestamp
else:
user = generate_user(
secret=current_app.config['SECRET_KEY'],
token=token,
broadcaster=broadcaster,
timestamp=timestamp,
)
current_app.users[token] = user
await broadcast(
current_app.websockets,
payload={
'type': 'add-user',
'user': user_for_websocket(user),
}
)
# Set cookie
response = await f(user, *args, **kwargs)
if context.cookies.get('token') != token:
response = await make_response(response)
response.headers['Set-Cookie'] = f'token={token}; path=/'
return response
return wrapper
return with_user_from_context

ファイルの表示

@ -2,6 +2,7 @@
const token = document.querySelector("body").dataset.token;
/* insert js-only markup */
const jsmarkup_style = '<style id="style_js"></style>'
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>';
@ -13,10 +14,14 @@ const jsmarkup_chat_form = `\
<span id="chat-live__ball"></span>
<span id="chat-live__status">Not connected to chat</span>
</div>
<input id="chat-form_js__submit" type="submit" value="Chat" disabled>
<input id="chat-form_js__submit" type="submit" value="Chat" accesskey="p" disabled>
</form>`;
const insert_jsmarkup = () => {
if (document.getElementById("style_js") === null) {
const parent = document.head;
parent.insertAdjacentHTML("beforeend", jsmarkup_style);
}
if (document.getElementById("info_js") === null) {
const parent = document.getElementById("info");
parent.insertAdjacentHTML("beforeend", jsmarkup_info);
@ -36,12 +41,75 @@ const insert_jsmarkup = () => {
}
insert_jsmarkup();
const stylesheet = document.styleSheets[1];
/* create websocket */
const info_title = document.getElementById("info_js__title");
const chat_messages_parent = document.getElementById("chat__messages");
const chat_messages = document.getElementById("chat-messages_js");
const create_chat_message = (object) => {
const user = users[object.token_hash];
const chat_message = document.createElement("li");
chat_message.classList.add("chat-message");
chat_message.dataset.id = object.id;
chat_message.dataset.tokenHash = object.token_hash;
const chat_message_name = document.createElement("span");
chat_message_name.classList.add("chat-message__name");
chat_message_name.innerText = user.name || default_name[user.broadcaster];
//chat_message_name.dataset.color = user.color; // not working in any browser
const chat_message_markup = document.createElement("span");
chat_message_markup.classList.add("chat-message__markup");
chat_message_markup.innerHTML = object.markup;
chat_message.insertAdjacentElement("beforeend", chat_message_name);
chat_message.insertAdjacentHTML("beforeend", ":&nbsp;");
chat_message.insertAdjacentElement("beforeend", chat_message_markup);
return chat_message
}
let users = {};
let default_name = {true: "Broadcaster", false: "Anonymous"};
const equal = (color1, color2) => {
/* comparing css colors is annoying */
return false;
}
const update_user_styles = () => {
const to_delete = [];
const to_ignore = new Set();
for (let index = 0; index < stylesheet.cssRules.length; index++) {
const css_rule = stylesheet.cssRules[index];
const match = css_rule.selectorText.match(/.chat-message\[data-token-hash="([a-z2-7]{26})"\] > .chat-message__name/);
const token_hash = match === null ? null : match[1];
const user = token_hash === null ? null : users[token_hash];
if (user === null || user === undefined) {
to_delete.push(index);
} else if (!equal(css_rule.style.color, user.color)) {
to_delete.push(index);
} else {
to_ignore.add(token_hash);
}
}
for (const token_hash of Object.keys(users)) {
if (!to_ignore.has(token_hash)) {
const user = users[token_hash];
stylesheet.insertRule(
`.chat-message[data-token-hash="${token_hash}"] > .chat-message__name { color: ${user.color}; }`,
stylesheet.cssRules.length,
);
}
}
for (const index of to_delete.reverse()) {
stylesheet.deleteRule(index);
}
}
const on_websocket_message = (event) => {
console.log("websocket message", event);
const receipt = JSON.parse(event.data);
switch (receipt.type) {
case "error":
@ -50,8 +118,29 @@ const on_websocket_message = (event) => {
case "init":
console.log("ws init", receipt);
chat_form_nonce.value = receipt.nonce;
info_title.innerText = receipt.title;
default_name = receipt.default;
users = receipt.users;
update_user_styles();
const ids = new Set(receipt.chat.map((message) => {return message.id;}));
for (const chat_message of chat_messages.children) {
if (!ids.has(parseInt(chat_message.dataset.id))) {
console.log('removing', chat_message);
chat_message.remove();
}
}
const last_id = Math.max(...[...chat_messages.children].map((element) => parseInt(element.dataset.id)));
for (const message of receipt.chat) {
if (message.id > last_id) {
const chat_message = create_chat_message(message);
chat_messages.insertAdjacentElement("beforeend", chat_message);
}
}
break;
case "title":
@ -79,33 +168,29 @@ const on_websocket_message = (event) => {
case "chat":
console.log("ws chat", receipt);
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.dataset.color = receipt.color; // not working in any browser
chat_message_name.style.color = receipt.color;
const chat_message_markup = document.createElement("span");
chat_message_markup.classList.add("chat-message__markup");
chat_message_markup.innerHTML = receipt.markup;
chat_message.insertAdjacentElement("beforeend", chat_message_name);
chat_message.insertAdjacentHTML("beforeend", ":&nbsp;");
chat_message.insertAdjacentElement("beforeend", chat_message_markup);
const chat_message = create_chat_message(receipt);
chat_messages.insertAdjacentElement("beforeend", chat_message);
chat_messages_parent.scrollTo({
chat_messages.scrollTo({
left: 0,
top: chat_messages_parent.scrollTopMax,
top: chat_messages.scrollTopMax,
behavior: "smooth",
});
break;
case "add-user":
console.log("ws add-user", receipt);
users[receipt.user.token_hash] = receipt.user;
update_user_styles();
break;
case "rem-users":
console.log("ws rem-users", receipt);
for (const token_hash of receipt.token_hashes) {
delete users[token_hash];
}
update_user_styles();
break;
default:
console.log("incomprehensible websocket message", receipt);
}
@ -123,13 +208,23 @@ const connect_websocket = () => {
chat_live_status.innerText = "Connecting to chat...";
ws = new WebSocket(`ws://${document.domain}:${location.port}/live?token=${encodeURIComponent(token)}`);
ws.addEventListener("open", (event) => {
console.log("websocket 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
// When the server is offline, a newly opened websocket can take a second
// to close. This timeout tries to ensure the backoff doesn't instantly
// (erroneously) reset to 2 seconds in that case.
setTimeout(() => {
if (event.target === ws) {
websocket_backoff = 2000; // 2 seconds
}
},
websocket_backoff + 4000,
);
});
ws.addEventListener("close", (event) => {
console.log("websocket closed", event);
console.log("websocket close", event);
chat_form_submit.disabled = true;
chat_live_ball.style.borderColor = "maroon";
chat_live_status.innerText = "Disconnected from chat";
@ -161,3 +256,23 @@ chat_form.addEventListener("submit", (event) => {
chat_form_submit.disabled = true;
ws.send(JSON.stringify(payload));
});
/* when chat is being resized, peg its bottom in place (instead of its top) */
const track_scroll = (element) => {
chat_messages.dataset.scrollTop = chat_messages.scrollTop;
chat_messages.dataset.scrollTopMax = chat_messages.scrollTopMax;
}
const peg_bottom = (entries) => {
for (const entry of entries) {
const element = entry.target;
const bottom = chat_messages.dataset.scrollTopMax - chat_messages.dataset.scrollTop;
element.scrollTop = chat_messages.scrollTopMax - bottom;
track_scroll(element);
}
}
const resize = new ResizeObserver(peg_bottom);
resize.observe(chat_messages);
chat_messages.addEventListener("scroll", (event) => {
track_scroll(chat_messages);
});
track_scroll(chat_messages);

ファイルの表示

@ -1,5 +1,5 @@
:root {
--text-color: white;
--text-color: #ddd;
--main-bg-color: #18181a;
--chat-bg-color: #232327;
@ -40,7 +40,7 @@ a {
}
iframe {
width: 100%;
border: 1px solid red;
border: none;
box-sizing: border-box;
}
noscript {
@ -83,14 +83,13 @@ noscript {
#chat__header {
text-align: center;
padding: 1ch 0;
margin-bottom: 1ch;
border-bottom: var(--chat-border);
}
#chat-form_js {
display: grid;
grid-template: auto var(--button-height) / auto 8ch;
grid-gap: 0.75ch;
margin: 1ch;
grid-template: auto var(--button-height) / auto 5rem;
grid-gap: 0.375rem;
margin: 0 1ch 1ch 1ch;
}
#chat-form_js__submit {
grid-column: 2 / span 1;
@ -101,9 +100,10 @@ noscript {
border-radius: 4px;
border: 2px solid transparent;
transition: 0.25s;
max-height: 16ch;
max-height: max(37.5vh, 16ch);
min-height: 1.75ch;
padding: 1.5ch;
height: 6ch;
padding: 0.675rem;
color: #c3c3c7;
resize: vertical;
}
@ -118,13 +118,13 @@ noscript {
height: 13ch;
}
#chat__messages {
overflow-y: auto;
position: relative;
}
#chat-messages_js {
list-style: none;
margin: 0;
padding: 0 1ch;
padding: 0 1ch 1ch;
overflow-y: auto;
width: 100%;
box-sizing: border-box;
max-height: 100%;
@ -145,8 +145,9 @@ noscript {
background-color: #434347;
}
.chat-message__name {
overflow-wrap: anywhere;
font-weight: bold;
color: attr("data-color");
/* color: attr("data-color"); */
cursor: default;
}
.chat-message__markup {
@ -238,7 +239,7 @@ footer {
display: grid;
border: none;
border-left: var(--chat-border);
min-height: 100vh;
min-height: 100%;
}
#both:target #info_nojs {
height: var(--nojs-info-height);

ファイルの表示

@ -12,7 +12,7 @@
<aside id="chat">
<header id="chat__header">Stream chat</header>
<article id="chat__messages">
<noscript><iframe id="chat-messages_nojs" src="{{ url_for('nojs_chat', token=user.token) }}" data-js="false"></iframe></noscript>
<noscript><iframe id="chat-messages_nojs" src="{{ url_for('nojs_chat', token=user.token, _anchor='bottom') }}" data-js="false"></iframe></noscript>
</article>
<section id="chat__form">
<noscript><iframe id="chat-form_nojs" src="{{ url_for('nojs_form', token=user.token, _anchor='notice') }}" data-js="false"></iframe></noscript>

59
anonstream/templates/nojs_chat.html ノーマルファイル
ファイルの表示

@ -0,0 +1,59 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="refresh" content="4">
<style>
html {
height: 100%;
}
body {
margin: 0;
height: 100%;
color: #ddd;
font-family: sans-serif;
font-size: 11pt;
}
#chat-messages {
list-style: none;
margin: 0;
padding: 1ch 1ch 0 1ch;
min-height: 100%;
box-sizing: border-box;
transform: rotate(180deg);
}
.chat-message {
padding: 0.5ch 0.75ch;
width: 100%;
box-sizing: border-box;
border-radius: 4px;
transform: rotate(-180deg);
}
.chat-message:hover {
background-color: #434347;
}
.chat-message__name {
overflow-wrap: anywhere;
font-weight: bold;
/* color: attr("data-color"); */
cursor: normal;
}
.chat-message__markup {
overflow-wrap: anywhere;
line-height: 1.3125;
}
</style>
</head>
<body>
<ul id="chat-messages">
{% for message in messages | reverse %}
<li class="chat-message">
{% with user = users[message.token] %}
<span class="chat-message__name" style="color:{{ user.color }};" data-id="{{ message.id }}">{{ user.name or get_default_name(user) }}</span>:&nbsp;<span class="chat-message__markup">{{ message.markup }}</span>
{% endwith %}
</li>
{% endfor %}
</ul>
<div id="bottom"></div>
</body>
</html>

ファイルの表示

@ -5,12 +5,16 @@
<style>
:root {
--link-color: #42a5d7;
--padding: 1ch;
--padding-size: 1ch;
}
html {
height: 100%;
}
body {
margin: 0;
height: 13ch;
color: white;
height: 100%;
color: #ddd;
font-family: sans-serif;
}
a {
color: var(--link-color);
@ -32,10 +36,13 @@
cursor: pointer;
margin-right: 4px;
}
.x {
font-size: 14pt;
}
#notice {
display: grid;
padding: var(--padding);
padding: 0 var(--padding-size);
text-align: center;
color: white;
text-decoration: none;
@ -44,21 +51,28 @@
align-items: center;
}
#notice h1 {
font-size: 18pt;
margin: 0;
font-size: 18pt;
line-height: 1.25;
}
#chat-form, #appearance-form {
padding: var(--padding);
padding: 0 var(--padding-size) var(--padding-size) var(--padding-size);
height: 100%;
box-sizing: border-box;
grid-gap: 0.75ch;
grid-gap: 0.375rem;
}
#chat-form__exit,
#appearance-form__exit,
#appearance-form__label-name,
#appearance-form__label-password {
font-size: 11pt;
align-self: center;
}
#chat-form {
display: grid;
grid: auto 2rem / auto 8ch;
grid-gap: 0.75ch;
grid: auto 2rem / auto 5rem;
}
#chat-form__comment {
resize: none;
@ -67,7 +81,7 @@
border-radius: 4px;
border: 2px solid transparent;
transition: 0.25s;
padding: 1.5ch;
padding: 0.675rem;
color: #c3c3c7;
}
#chat-form__comment:not(:focus):hover {
@ -91,7 +105,16 @@
#appearance-form__buttons {
grid-column: 1 / span 3;
display: grid;
grid-template-columns: auto 8ch;
grid-template-columns: auto 5rem;
}
#password-column {
display: grid;
grid-template-columns: auto auto 1fr;
grid-gap: 0.25rem;
align-items: center;
}
#appearance-form label:not(.tripcode):not(.x) {
font-size: 11pt;
}
#password-toggle,
@ -164,29 +187,29 @@
<input type="hidden" name="nonce" value="{{ nonce }}">
<textarea id="chat-form__comment" name="comment" placeholder="Send a message..." required rows="1" tabindex="1"></textarea>
<div id="chat-form__exit"><a href="#appearance">Settings</a></div>
<input id="chat-form__submit" type="submit" value="Chat" tabindex="2">
<input id="chat-form__submit" type="submit" value="Chat" tabindex="2" accesskey="p">
</form>
<form id="appearance-form" action="/{{ url_for('nojs_submit_appearance', token=user.token) }}" method="post">
<label for="appearance-form__name">Name:</label>
<label id="appearance-form__label-name" 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" accesskey="s">
<input id="cleared-toggle" name="clear-tripcode" type="checkbox" accesskey="c">
<input id="cleared-toggle" name="clear-tripcode" type="checkbox"{% if user.tripcode != none %} accesskey="c"{% endif %}>
<div id="password-column">
{% if user.tripcode == none %}
<span class="tripcode">(no tripcode)</span>
<label for="password-toggle" class="show-password pseudolink">set</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 }}digest</label>
<label id="show-cleared" for="cleared-toggle" class="pseudolink">&cross;</label>
<label id="show-cleared" for="cleared-toggle" class="pseudolink x">&cross;</label>
<div id="cleared" class="tripcode">(cleared)</div>
<label id="hide-cleared" for="cleared-toggle" class="pseudolink">undo</label>
{% endif %}
</div>
<input id="appearance-form__password" name="password" type="password" placeholder="(tripcode password)">
<label id="hide-password" for="password-toggle" class="pseudolink">&cross;</label>
<label id="hide-password" for="password-toggle" class="pseudolink x">&cross;</label>
<div id="appearance-form__buttons">
<div><a href="#chat">Return to chat</a></div>
<div id="appearance-form__exit"><a href="#chat">Return to chat</a></div>
<input type="submit" value="Update">
</div>
</form>

ファイルの表示

@ -6,9 +6,10 @@
body {
overflow-y: scroll;
margin: 1ch 1.5ch;
font-family: sans-serif;
}
#title {
color: white;
color: #ddd;
font-size: 18pt;
}
</style>

ファイルの表示

@ -1,7 +1,12 @@
import time
from math import inf
from quart import current_app
from anonstream.wrappers import with_timestamp, with_first_argument
from anonstream.utils.users import user_for_websocket
from anonstream.utils import listmap
def get_default_name(user):
return (
current_app.config['DEFAULT_HOST_NAME']
@ -22,3 +27,56 @@ def pop_notice(user, notice_id):
except KeyError:
notice = None
return notice
def see(user):
user['seen']['last'] = int(time.time())
@with_timestamp
def users_for_websocket(timestamp, messages, users):
visible_users = filter(
lambda user: is_visible(timestamp, messages, user),
users.values(),
)
return {
user['token_hash']: user_for_websocket(user, include_token_hash=False)
for user in visible_users
}
def is_watching(timestamp, user):
return user['watching_last'] >= timestamp - current_app.config['THRESHOLD_IDLE']
def is_idle(timestamp, user):
return is_present(timestamp, user) and not is_watching(timestamp, user)
def is_present(timestamp, user):
return user['seen']['last'] >= timestamp - current_app.config['THRESHOLD_ABSENT']
def is_absent(timestamp, user):
return not is_present(timestamp, user)
def is_visible(timestamp, messages, user):
has_visible_messages = any(
message['token'] == user['token'] for message in messages
)
return has_visible_messages or is_present(timestamp, user)
last_checkup = -inf
def sunset(messages, users):
global last_checkup
timestamp = int(time.time())
if timestamp - last_checkup < current_app.config['USER_CHECKUP_PERIOD']:
return []
to_delete = []
for token in users:
user = users[token]
if not is_visible(timestamp, messages, user):
to_delete.append(token)
for index, token in enumerate(to_delete):
to_delete[index] = users.pop(token)['token_hash']
last_checkup = timestamp
return to_delete

2
anonstream/utils/__init__.py ノーマルファイル
ファイルの表示

@ -0,0 +1,2 @@
def listmap(*args, **kwargs):
return list(map(*args, **kwargs))

ファイルの表示

@ -8,13 +8,16 @@ class NonceReuse(Exception):
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()
def generate_nonce_hash(secret, nonce):
parts = secret + b'nonce-hash\0' + nonce.encode()
return hashlib.sha256(parts).digest()
def create_message(message_ids, secret, nonce, comment):
message_id = generate_message_id(secret, nonce)
if message_id in message_ids:
raise NonceReuse
return message_id, nonce, comment
def message_for_websocket(users, message):
message_keys = ('id', 'date', 'time_minutes', 'time_seconds', 'markup')
user_keys = ('token_hash',)
user = users[message['token']]
return {
**{key: message[key] for key in message_keys},
**{key: user[key] for key in user_keys},
}

ファイルの表示

@ -1,18 +1,35 @@
import base64
import hashlib
import secrets
from collections import OrderedDict
from math import inf
def generate_token():
return secrets.token_hex(16)
def generate_user(token, broadcaster, timestamp):
def generate_token_hash(secret, token):
parts = secret + b'token-hash\0' + token.encode()
digest = hashlib.sha256(parts).digest()
return base64.b32encode(digest)[:26].lower().decode()
def generate_user(secret, token, broadcaster, timestamp):
return {
'token': token,
'token_hash': generate_token_hash(secret, token),
'broadcaster': broadcaster,
'name': None,
'color': '#c7007f',
'tripcode': None,
'notices': OrderedDict(),
'seen': {
'first': timestamp,
'last': timestamp,
},
'watching_last': -inf,
}
def user_for_websocket(user, include_token_hash=True):
keys = ['broadcaster', 'name', 'color', 'tripcode']
if include_token_hash:
keys.append('token_hash')
return {key: user[key] for key in keys}

ファイルの表示

@ -1,9 +1,7 @@
from anonstream.utils.chat import create_message, NonceReuse
class Malformed(Exception):
pass
def parse_websocket_data(message_ids, secret, receipt):
def parse_websocket_data(receipt):
if not isinstance(receipt, dict):
raise Malformed('not a json object')
@ -15,9 +13,4 @@ def parse_websocket_data(message_ids, secret, receipt):
if not isinstance(nonce, str):
raise Malformed('malformed nonce')
try:
message = create_message(message_ids, secret, nonce, comment)
except NonceReuse:
raise Malformed('nonce already used')
return message
return nonce, comment

ファイルの表示

@ -4,27 +4,36 @@ from quart import websocket
from anonstream.stream import get_stream_title, get_stream_uptime
from anonstream.chat import broadcast, add_chat_message, Rejected
from anonstream.utils.chat import generate_nonce
from anonstream.utils.websocket import parse_websocket_data
from anonstream.users import is_present, users_for_websocket, see
from anonstream.wrappers import with_first_argument
from anonstream.utils import listmap
from anonstream.utils.chat import generate_nonce, message_for_websocket
from anonstream.utils.websocket import parse_websocket_data, Malformed
async def websocket_outbound(queue):
async def websocket_outbound(queue, messages, users, default_host_name, default_anon_name):
payload = {
'type': 'init',
'nonce': generate_nonce(),
'title': get_stream_title(),
'uptime': get_stream_uptime(),
'chat': [],
'chat': listmap(
with_first_argument(users)(message_for_websocket),
messages,
),
'users': users_for_websocket(messages, users),
'default': {True: default_host_name, False: default_anon_name},
}
await websocket.send_json(payload)
while True:
payload = await queue.get()
await websocket.send_json(payload)
async def websocket_inbound(queue, connected_websockets, token, secret, chat):
async def websocket_inbound(queue, chat, users, connected_websockets, user, secret):
while True:
receipt = await websocket.receive_json()
see(user)
try:
message_id, nonce, comment = parse_websocket_data(chat.keys(), secret, receipt)
nonce, comment = parse_websocket_data(receipt)
except Malformed as e:
error , *_ = e.args
payload = {
@ -35,10 +44,12 @@ async def websocket_inbound(queue, connected_websockets, token, secret, chat):
try:
markup = await add_chat_message(
chat,
users,
connected_websockets,
token,
message_id,
comment
secret,
user,
nonce,
comment,
)
except Rejected as e:
notice, *_ = e.args

ファイルの表示

@ -1,55 +1,20 @@
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.users import generate_token, generate_user
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(current_app.config["AUTH_PWHASH"], auth.password)
)
def auth_required(f):
def with_timestamp(f):
@wraps(f)
async def wrapper(*args, **kwargs):
if check_auth(request):
return await f(*args, **kwargs)
hint = 'The broadcaster should log in with the credentials printed ' \
'in their terminal.'
body = (
f'<p>{hint}</p>'
if request.authorization is None else
'<p>Wrong username or password. Refresh the page to try again.</p>'
f'<p>{hint}</p>'
)
return body, 401, {'WWW-Authenticate': 'Basic'}
def wrapper(*args, **kwargs):
timestamp = int(time.time())
return f(timestamp, *args, **kwargs)
return wrapper
def with_user_from(context):
def with_user_from_context(f):
def with_first_argument(x):
def with_x(f):
@wraps(f)
async def wrapper(*args, **kwargs):
broadcaster = check_auth(context)
if broadcaster:
token = current_app.config['AUTH_TOKEN']
else:
token = context.args.get('token') or generate_token()
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)
current_app.users[token] = user
return await f(user, *args, **kwargs)
def wrapper(*args, **kwargs):
return f(x, *args, **kwargs)
return wrapper
return with_user_from_context
return with_x

ファイルの表示

@ -12,3 +12,13 @@ anonymous = "Anonymous"
[limits]
notices = 32
chat_storage = 8192
chat_scrollback = 256
[ratelimits]
user_absence = 8
captcha_expiry = 8
[thresholds]
idle = 12
absent = 1800