Nojs comment submission, notify for rejected comments

Fix with-user wrapper (wasn't collecting users)
このコミットが含まれているのは:
n9k 2022-02-15 10:11:53 +00:00
コミット 694c6a4995
13個のファイルの変更245行の追加100行の削除

ファイルの表示

@ -14,20 +14,21 @@ async def create_app():
auth_password = secrets.token_urlsafe(6) auth_password = secrets.token_urlsafe(6)
auth_pwhash = generate_password_hash(auth_password) auth_pwhash = generate_password_hash(auth_password)
print('Broadcaster username:', config['auth_username']) print('Broadcaster username:', config['auth']['username'])
print('Broadcaster password:', auth_password) print('Broadcaster password:', auth_password)
app = Quart('anonstream') app = Quart('anonstream')
app.config['SECRET_KEY'] = config['secret_key'].encode() app.config['SECRET_KEY'] = config['secret_key'].encode()
app.config['AUTH_USERNAME'] = config['auth_username'] app.config['AUTH_USERNAME'] = config['auth']['username']
app.config['AUTH_PWHASH'] = auth_pwhash app.config['AUTH_PWHASH'] = auth_pwhash
app.config['AUTH_TOKEN'] = generate_token() app.config['AUTH_TOKEN'] = generate_token()
app.config['DEFAULT_HOST_NAME'] = config['default_host_name'] app.config['DEFAULT_HOST_NAME'] = config['names']['broadcaster']
app.config['DEFAULT_ANON_NAME'] = config['default_anon_name'] app.config['DEFAULT_ANON_NAME'] = config['names']['anonymous']
app.config['LIMIT_NOTICES'] = config['limits']['notices']
app.chat = OrderedDict() app.chat = OrderedDict()
app.users = {} app.users = {}
app.websockets = set() app.websockets = set()
app.segments_directory_cache = DirectoryCache(config['segments_dir']) app.segments_directory_cache = DirectoryCache(config['stream']['segments_dir'])
async with app.app_context(): async with app.app_context():
import anonstream.routes import anonstream.routes

ファイルの表示

@ -2,9 +2,21 @@ from datetime import datetime
from quart import escape from quart import escape
def add_chat_message(chat, message_id, token, text): class Rejected(Exception):
pass
async def broadcast(websockets, payload):
for queue in websockets:
await queue.put(payload)
async def add_chat_message(chat, websockets, token, message_id, comment):
# check message
if len(comment) == 0:
raise Rejected('Message was empty')
# add message
dt = datetime.utcnow() dt = datetime.utcnow()
markup = escape(text) markup = escape(comment)
chat[message_id] = { chat[message_id] = {
'id': message_id, 'id': message_id,
'token': token, 'token': token,
@ -12,6 +24,19 @@ def add_chat_message(chat, message_id, token, text):
'date': dt.strftime('%Y-%m-%d'), 'date': dt.strftime('%Y-%m-%d'),
'time_minutes': dt.strftime('%H:%M'), 'time_minutes': dt.strftime('%H:%M'),
'time_seconds': dt.strftime('%H:%M:%S'), 'time_seconds': dt.strftime('%H:%M:%S'),
'nomarkup': text, 'nomarkup': comment,
'markup': markup, 'markup': markup,
} }
# broadcast message to websockets
await broadcast(
websockets,
payload={
'type': 'chat',
'color': '#c7007f',
'name': 'Anonymous',
'markup': markup,
}
)
return markup

ファイルの表示

@ -1,12 +1,14 @@
import asyncio import asyncio
from quart import current_app, request, render_template, make_response, redirect, websocket from quart import current_app, request, render_template, make_response, redirect, websocket, url_for
from anonstream.stream import get_stream_title from anonstream.stream import get_stream_title
from anonstream.segments import CatSegments, Offline from anonstream.segments import CatSegments, Offline
from anonstream.users import get_default_name from anonstream.users import get_default_name, add_notice, pop_notice
from anonstream.wrappers import with_user_from, auth_required from anonstream.wrappers import with_user_from, auth_required
from anonstream.websocket import websocket_outbound, websocket_inbound 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('/') @current_app.route('/')
@with_user_from(request) @with_user_from(request)
@ -69,8 +71,51 @@ async def nojs_chat(user):
@current_app.route('/chat/form.html') @current_app.route('/chat/form.html')
@with_user_from(request) @with_user_from(request)
async def nojs_form(user): 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( return await render_template(
'nojs_form.html', 'nojs_form.html',
user=user, user=user,
notice=pop_notice(user, notice_id),
prefer_chat_form=prefer_chat_form,
nonce=generate_nonce(),
default_name=get_default_name(user), 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

ファイルの表示

@ -8,7 +8,7 @@ const jsmarkup_chat_messages = '<ul id="chat-messages_js" data-js="true"></ul>';
const jsmarkup_chat_form = `\ const jsmarkup_chat_form = `\
<form id="chat-form_js" data-js="true" action="/chat" method="post"> <form id="chat-form_js" data-js="true" action="/chat" method="post">
<input id="chat-form_js__nonce" type="hidden" name="nonce" value=""> <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> <textarea id="chat-form_js__comment" name="comment" maxlength="512" required placeholder="Send a message..." rows="1"></textarea>
<div id="chat-live"> <div id="chat-live">
<span id="chat-live__ball"></span> <span id="chat-live__ball"></span>
<span id="chat-live__status">Not connected to chat</span> <span id="chat-live__status">Not connected to chat</span>
@ -45,24 +45,24 @@ const on_websocket_message = (event) => {
const receipt = JSON.parse(event.data); const receipt = JSON.parse(event.data);
switch (receipt.type) { switch (receipt.type) {
case "error": case "error":
console.log("server sent error via websocket", receipt); console.log("ws error", receipt);
break; break;
case "init": case "init":
console.log("init", receipt); console.log("ws init", receipt);
chat_form_nonce.value = receipt.nonce; chat_form_nonce.value = receipt.nonce;
info_title.innerText = receipt.title; info_title.innerText = receipt.title;
break; break;
case "title": case "title":
console.log("title", receipt); console.log("ws title", receipt);
info_title.innerText = receipt.title; info_title.innerText = receipt.title;
break; break;
case "ack": case "ack":
console.log("ack", receipt); console.log("ws ack", receipt);
if (chat_form_nonce.value === receipt.nonce) { if (chat_form_nonce.value === receipt.nonce) {
chat_form_message.value = ""; chat_form_comment.value = "";
} else { } else {
console.log("nonce does not match ack", chat_form_nonce, receipt); console.log("nonce does not match ack", chat_form_nonce, receipt);
} }
@ -70,7 +70,15 @@ const on_websocket_message = (event) => {
chat_form_nonce.value = receipt.next; chat_form_nonce.value = receipt.next;
break; break;
case "reject":
console.log("ws reject", receipt);
alert(`Rejected: ${receipt.notice}`);
chat_form_submit.disabled = false;
break;
case "chat": case "chat":
console.log("ws chat", receipt);
const chat_message = document.createElement("li"); const chat_message = document.createElement("li");
chat_message.classList.add("chat-message"); chat_message.classList.add("chat-message");
@ -80,13 +88,13 @@ const on_websocket_message = (event) => {
//chat_message_name.dataset.color = receipt.color; // not working in any browser //chat_message_name.dataset.color = receipt.color; // not working in any browser
chat_message_name.style.color = receipt.color; chat_message_name.style.color = receipt.color;
const chat_message_text = document.createElement("span"); const chat_message_markup = document.createElement("span");
chat_message_text.classList.add("chat-message__text"); chat_message_markup.classList.add("chat-message__markup");
chat_message_text.innerText = receipt.text; chat_message_markup.innerHTML = receipt.markup;
chat_message.insertAdjacentElement("beforeend", chat_message_name); chat_message.insertAdjacentElement("beforeend", chat_message_name);
chat_message.insertAdjacentHTML("beforeend", ":&nbsp;"); chat_message.insertAdjacentHTML("beforeend", ":&nbsp;");
chat_message.insertAdjacentElement("beforeend", chat_message_text); chat_message.insertAdjacentElement("beforeend", chat_message_markup);
chat_messages.insertAdjacentElement("beforeend", chat_message); chat_messages.insertAdjacentElement("beforeend", chat_message);
chat_messages_parent.scrollTo({ chat_messages_parent.scrollTo({
@ -95,11 +103,10 @@ const on_websocket_message = (event) => {
behavior: "smooth", behavior: "smooth",
}); });
console.log("chat", receipt);
break; break;
default: default:
console.log("incomprehensible websocket message", message); console.log("incomprehensible websocket message", receipt);
} }
}; };
const chat_live_ball = document.getElementById("chat-live__ball"); const chat_live_ball = document.getElementById("chat-live__ball");
@ -145,11 +152,11 @@ connect_websocket();
/* override js-only chat form */ /* override js-only chat form */
const chat_form = document.getElementById("chat-form_js"); const chat_form = document.getElementById("chat-form_js");
const chat_form_nonce = document.getElementById("chat-form_js__nonce"); const chat_form_nonce = document.getElementById("chat-form_js__nonce");
const chat_form_message = document.getElementById("chat-form_js__message"); const chat_form_comment = document.getElementById("chat-form_js__comment");
const chat_form_submit = document.getElementById("chat-form_js__submit"); const chat_form_submit = document.getElementById("chat-form_js__submit");
chat_form.addEventListener("submit", (event) => { chat_form.addEventListener("submit", (event) => {
event.preventDefault(); event.preventDefault();
const payload = {message: chat_form_message.value, nonce: chat_form_nonce.value}; const payload = {comment: chat_form_comment.value, nonce: chat_form_nonce.value};
chat_form_submit.disabled = true; chat_form_submit.disabled = true;
ws.send(JSON.stringify(payload)); ws.send(JSON.stringify(payload));
}); });

ファイルの表示

@ -95,7 +95,7 @@ noscript {
#chat-form_js__submit { #chat-form_js__submit {
grid-column: 2 / span 1; grid-column: 2 / span 1;
} }
#chat-form_js__message { #chat-form_js__comment {
grid-column: 1 / span 2; grid-column: 1 / span 2;
background-color: #434347; background-color: #434347;
border-radius: 4px; border-radius: 4px;
@ -107,10 +107,10 @@ noscript {
color: #c3c3c7; color: #c3c3c7;
resize: vertical; resize: vertical;
} }
#chat-form_js__message:not(:focus):hover { #chat-form_js__comment:not(:focus):hover {
border-color: #737377; border-color: #737377;
} }
#chat-form_js__message:focus { #chat-form_js__comment:focus {
background-color: black; background-color: black;
border-color: #3584e4; border-color: #3584e4;
} }
@ -149,7 +149,7 @@ noscript {
color: attr("data-color"); color: attr("data-color");
cursor: default; cursor: default;
} }
.chat-message__text { .chat-message__markup {
overflow-wrap: anywhere; overflow-wrap: anywhere;
line-height: 1.3125; line-height: 1.3125;
} }

ファイルの表示

@ -4,6 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<style> <style>
:root { :root {
--link-color: #42a5d7;
--padding: 1ch; --padding: 1ch;
} }
body { body {
@ -12,20 +13,29 @@
color: white; color: white;
} }
a { a {
color: #42a5d7; color: var(--link-color);
} }
label { .pseudolink {
color: var(--link-color);
cursor: pointer; cursor: pointer;
} }
.pseudolink:hover {
text-decoration: underline;
}
.tripcode { .tripcode {
padding: 0 4px; padding: 0 4px;
border-radius: 7px; border-radius: 7px;
font-family: monospace; font-family: monospace;
align-self: center; cursor: default;
}
#tripcode {
cursor: pointer;
margin-right: 4px;
} }
#notice { #notice {
padding: var(--margin); display: grid;
padding: var(--padding);
text-align: center; text-align: center;
color: white; color: white;
text-decoration: none; text-decoration: none;
@ -36,6 +46,7 @@
#notice h1 { #notice h1 {
font-size: 18pt; font-size: 18pt;
margin: 0; margin: 0;
line-height: 1.25;
} }
#chat-form, #appearance-form { #chat-form, #appearance-form {
@ -49,7 +60,7 @@
grid: auto 2rem / auto 8ch; grid: auto 2rem / auto 8ch;
grid-gap: 0.75ch; grid-gap: 0.75ch;
} }
#chat-form__message { #chat-form__comment {
resize: none; resize: none;
grid-column: 1 / span 2; grid-column: 1 / span 2;
background-color: #434347; background-color: #434347;
@ -59,10 +70,10 @@
padding: 1.5ch; padding: 1.5ch;
color: #c3c3c7; color: #c3c3c7;
} }
#chat-form__message:not(:focus):hover { #chat-form__comment:not(:focus):hover {
border-color: #737377; border-color: #737377;
} }
#chat-form__message:focus { #chat-form__comment:focus {
background-color: black; background-color: black;
border-color: #3584e4; border-color: #3584e4;
} }
@ -114,38 +125,48 @@
display: none; display: none;
} }
#notice, #appearance-form { #appearance-form {
display: none; display: none;
} }
#anchor:target > #chat-form { #appearance:target ~ #appearance-form {
display: none;
}
#anchor:target > #appearance-form {
display: grid; display: grid;
} }
#notice:target { #appearance:target ~ #chat-form {
display: grid;
}
#notice:target + #chat-form,
#notice:target ~ #appearance-form {
display: none; display: none;
} }
#chat:target ~ #appearance-form {
display: none;
}
{% if notice != none %}
#chat-form {
display: none;
}
#chat:target ~ #chat-form {
display: grid;
}
#chat:target ~ #notice,
#appearance:target ~ #notice {
display: none;
}
{% endif %}
</style> </style>
</head> </head>
<body id="anchor"> <body>
<a href="#chat-form" id="notice"> <div id="chat"></div>
<header><h1>No notice</h1></header> <div id="appearance"></div>
<small>Click to dismiss</small> {% if notice != none %}
</a> <a id="notice" {% if prefer_chat_form %}href="#chat"{% else %}href="#appearance"{% endif %}>
</article> <header><h1>{{ notice }}</h1></header>
<form id="chat-form" action="/chat/message"> <small>Click to dismiss</small>
<input type="hidden" name="token" value="{{ user.token }}"> </a>
<textarea id="chat-form__message" name="text" placeholder="Send a message..." required rows="1"></textarea> {% endif %}
<div id="chat-form__exit"><a href="#anchor">Settings</a></div> <form id="chat-form" action="{{ url_for('nojs_submit_message', token=user.token) }}" method="post">
<input id="chat-form__submit" type="submit" value="Chat"> <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">
</form> </form>
<form id="appearance-form" action="/chat/appearance"> <form id="appearance-form" action="/{{ url_for('nojs_submit_appearance', token=user.token) }}" method="post">
<input type="hidden" name="token" value="{{ user.token }}">
<label for="appearance-form__name">Name:</label> <label for="appearance-form__name">Name:</label>
<input id="appearance-form__name" name="name" value="{{ user.name or '' }}" placeholder="{{ user.name or default_name }}"> <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> <label id="appearance-form__label-password" for="appearance-form__password">Tripcode:</label>
@ -153,18 +174,19 @@
<input id="cleared-toggle" name="clear-tripcode" type="checkbox"> <input id="cleared-toggle" name="clear-tripcode" type="checkbox">
<div id="password-column"> <div id="password-column">
{% if user.tripcode == none %} {% if user.tripcode == none %}
<label class="show-password tripcode" for="password-toggle">(no tripcode)</label> <span class="tripcode">(no tripcode)</span>
<label for="password-toggle" class="show-password pseudolink" accesskey="s">set</label>
{% else %} {% 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="tripcode" for="password-toggle" class="show-password tripcode" style="background-color:{{ user.tripcode.background }};color:{{ user.tripcode.foreground }};" accesskey="s">{{ user.tripcode.digest }}digest</label>
<label id="show-cleared" for="cleared-toggle">&cross;</label> <label id="show-cleared" for="cleared-toggle" class="pseudolink" accesskey="c">&cross;</label>
<div id="cleared" class="tripcode">(cleared)</div> <div id="cleared" class="tripcode">(cleared)</div>
<label id="hide-cleared" for="cleared-toggle">undo</label> <label id="hide-cleared" for="cleared-toggle" class="pseudolink" accesskey="c">undo</label>
{% endif %} {% endif %}
</div> </div>
<input id="appearance-form__password" name="password" type="password"></div> <input id="appearance-form__password" name="password" type="password" placeholder="(tripcode password)">
<label id="hide-password" for="password-toggle">&cross;</label> <label id="hide-password" for="password-toggle" class="pseudolink" accesskey="s">&cross;</label>
<div id="appearance-form__buttons"> <div id="appearance-form__buttons">
<div><a href="#chat-form">Return to chat</a></div> <div><a href="#chat">Return to chat</a></div>
<input type="submit" value="Update"> <input type="submit" value="Update">
</div> </div>
</form> </form>

ファイルの表示

@ -1,3 +1,5 @@
import time
from quart import current_app from quart import current_app
def get_default_name(user): def get_default_name(user):
@ -7,3 +9,16 @@ def get_default_name(user):
current_app.config['DEFAULT_ANON_NAME'] current_app.config['DEFAULT_ANON_NAME']
) )
def add_notice(user, notice):
notice_id = time.time_ns() // 1_000_000
user['notices'][notice_id] = notice
if len(user['notices']) > current_app.config['LIMIT_NOTICES']:
user['notices'].popitem(last=False)
return notice_id
def pop_notice(user, notice_id):
try:
notice = user['notices'].pop(notice_id)
except KeyError:
notice = None
return notice

ファイルの表示

@ -2,6 +2,9 @@ import base64
import hashlib import hashlib
import secrets import secrets
class NonceReuse(Exception):
pass
def generate_nonce(): def generate_nonce():
return secrets.token_urlsafe(16) return secrets.token_urlsafe(16)
@ -9,3 +12,9 @@ def generate_message_id(secret, nonce):
parts = secret + b'message-id\0' + nonce.encode() parts = secret + b'message-id\0' + nonce.encode()
digest = hashlib.sha256(parts).digest() digest = hashlib.sha256(parts).digest()
return base64.urlsafe_b64encode(digest)[:22].decode() return base64.urlsafe_b64encode(digest)[:22].decode()
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

ファイルの表示

@ -1,4 +1,5 @@
import secrets import secrets
from collections import OrderedDict
def generate_token(): def generate_token():
return secrets.token_hex(16) return secrets.token_hex(16)
@ -9,6 +10,7 @@ def generate_user(token, broadcaster, timestamp):
'broadcaster': broadcaster, 'broadcaster': broadcaster,
'name': None, 'name': None,
'tripcode': None, 'tripcode': None,
'notices': OrderedDict(),
'seen': { 'seen': {
'first': timestamp, 'first': timestamp,
'last': timestamp, 'last': timestamp,

ファイルの表示

@ -1,19 +1,23 @@
from anonstream.utils.chat import generate_message_id from anonstream.utils.chat import create_message, NonceReuse
def parse(message_ids, secret, receipt): class Malformed(Exception):
pass
def parse_websocket_data(message_ids, secret, receipt):
if not isinstance(receipt, dict): if not isinstance(receipt, dict):
return None, 'not a json object' raise Malformed('not a json object')
message = receipt.get('message') comment = receipt.get('comment')
if not isinstance(message, str): if not isinstance(comment, str):
return None, 'malformed chat message' raise Malformed('malformed comment')
nonce = receipt.get('nonce') nonce = receipt.get('nonce')
if not isinstance(nonce, str): if not isinstance(nonce, str):
return None, 'malformed nonce' raise Malformed('malformed nonce')
message_id = generate_message_id(secret, nonce) try:
if message_id in message_ids: message = create_message(message_ids, secret, nonce, comment)
return None, 'nonce already used' except NonceReuse:
raise Malformed('nonce already used')
return (message, nonce, message_id), None return message

ファイルの表示

@ -3,9 +3,9 @@ import asyncio
from quart import websocket from quart import websocket
from anonstream.stream import get_stream_title, get_stream_uptime from anonstream.stream import get_stream_title, get_stream_uptime
from anonstream.chat import add_chat_message from anonstream.chat import broadcast, add_chat_message, Rejected
from anonstream.utils.chat import generate_nonce from anonstream.utils.chat import generate_nonce
from anonstream.utils.websocket import parse from anonstream.utils.websocket import parse_websocket_data
async def websocket_outbound(queue): async def websocket_outbound(queue):
payload = { payload = {
@ -23,28 +23,33 @@ async def websocket_outbound(queue):
async def websocket_inbound(queue, connected_websockets, token, secret, chat): async def websocket_inbound(queue, connected_websockets, token, secret, chat):
while True: while True:
receipt = await websocket.receive_json() receipt = await websocket.receive_json()
receipt, error = parse(chat.keys(), secret, receipt) try:
if error is not None: message_id, nonce, comment = parse_websocket_data(chat.keys(), secret, receipt)
except Malformed as e:
error , *_ = e.args
payload = { payload = {
'type': 'error', 'type': 'error',
'because': error, 'because': error,
} }
else: else:
text, nonce, message_id = receipt try:
add_chat_message(chat, message_id, token, text) markup = await add_chat_message(
payload = { chat,
'type': 'ack', connected_websockets,
'nonce': nonce, token,
'next': generate_nonce(), message_id,
} comment
)
except Rejected as e:
notice, *_ = e.args
payload = {
'type': 'reject',
'notice': notice,
}
else:
payload = {
'type': 'ack',
'nonce': nonce,
'next': generate_nonce(),
}
await queue.put(payload) await queue.put(payload)
if error is None:
payload = {
'type': 'chat',
'color': '#c7007f',
'name': 'Anonymous',
'text': text,
}
for other_queue in connected_websockets:
await other_queue.put(payload)

ファイルの表示

@ -47,6 +47,7 @@ def with_user_from(context):
user['seen']['last'] = timestamp user['seen']['last'] = timestamp
else: else:
user = generate_user(token, broadcaster, timestamp) user = generate_user(token, broadcaster, timestamp)
current_app.users[token] = user
return await f(user, *args, **kwargs) return await f(user, *args, **kwargs)
return wrapper return wrapper

ファイルの表示

@ -1,5 +1,14 @@
secret_key = "test" secret_key = "test"
auth_username = "broadcaster"
[auth]
username = "broadcaster"
[stream]
segments_dir = "stream/" segments_dir = "stream/"
default_host_name = "Broadcaster"
default_anon_name = "Anonymous" [names]
broadcaster = "Broadcaster"
anonymous = "Anonymous"
[limits]
notices = 32