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_pwhash = generate_password_hash(auth_password)
print('Broadcaster username:', config['auth_username'])
print('Broadcaster username:', config['auth']['username'])
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_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.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.users = {}
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():
import anonstream.routes

ファイルの表示

@ -2,9 +2,21 @@ from datetime import datetime
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()
markup = escape(text)
markup = escape(comment)
chat[message_id] = {
'id': message_id,
'token': token,
@ -12,6 +24,19 @@ def add_chat_message(chat, message_id, token, text):
'date': dt.strftime('%Y-%m-%d'),
'time_minutes': dt.strftime('%H:%M'),
'time_seconds': dt.strftime('%H:%M:%S'),
'nomarkup': text,
'nomarkup': comment,
'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
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.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.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)
@ -69,8 +71,51 @@ async def nojs_chat(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

ファイルの表示

@ -8,7 +8,7 @@ const jsmarkup_chat_messages = '<ul id="chat-messages_js" data-js="true"></ul>';
const jsmarkup_chat_form = `\
<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>
<textarea id="chat-form_js__comment" name="comment" 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>
@ -45,24 +45,24 @@ const on_websocket_message = (event) => {
const receipt = JSON.parse(event.data);
switch (receipt.type) {
case "error":
console.log("server sent error via websocket", receipt);
console.log("ws error", receipt);
break;
case "init":
console.log("init", receipt);
console.log("ws init", receipt);
chat_form_nonce.value = receipt.nonce;
info_title.innerText = receipt.title;
break;
case "title":
console.log("title", receipt);
console.log("ws title", receipt);
info_title.innerText = receipt.title;
break;
case "ack":
console.log("ack", receipt);
console.log("ws ack", receipt);
if (chat_form_nonce.value === receipt.nonce) {
chat_form_message.value = "";
chat_form_comment.value = "";
} else {
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;
break;
case "reject":
console.log("ws reject", receipt);
alert(`Rejected: ${receipt.notice}`);
chat_form_submit.disabled = false;
break;
case "chat":
console.log("ws chat", receipt);
const chat_message = document.createElement("li");
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.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;
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_text);
chat_message.insertAdjacentElement("beforeend", chat_message_markup);
chat_messages.insertAdjacentElement("beforeend", chat_message);
chat_messages_parent.scrollTo({
@ -95,11 +103,10 @@ const on_websocket_message = (event) => {
behavior: "smooth",
});
console.log("chat", receipt);
break;
default:
console.log("incomprehensible websocket message", message);
console.log("incomprehensible websocket message", receipt);
}
};
const chat_live_ball = document.getElementById("chat-live__ball");
@ -145,11 +152,11 @@ connect_websocket();
/* override js-only chat form */
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_comment = document.getElementById("chat-form_js__comment");
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};
const payload = {comment: chat_form_comment.value, nonce: chat_form_nonce.value};
chat_form_submit.disabled = true;
ws.send(JSON.stringify(payload));
});

ファイルの表示

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

ファイルの表示

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

ファイルの表示

@ -1,3 +1,5 @@
import time
from quart import current_app
def get_default_name(user):
@ -7,3 +9,16 @@ def get_default_name(user):
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 secrets
class NonceReuse(Exception):
pass
def generate_nonce():
return secrets.token_urlsafe(16)
@ -9,3 +12,9 @@ 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 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
from collections import OrderedDict
def generate_token():
return secrets.token_hex(16)
@ -9,6 +10,7 @@ def generate_user(token, broadcaster, timestamp):
'broadcaster': broadcaster,
'name': None,
'tripcode': None,
'notices': OrderedDict(),
'seen': {
'first': 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):
return None, 'not a json object'
raise Malformed('not a json object')
message = receipt.get('message')
if not isinstance(message, str):
return None, 'malformed chat message'
comment = receipt.get('comment')
if not isinstance(comment, str):
raise Malformed('malformed comment')
nonce = receipt.get('nonce')
if not isinstance(nonce, str):
return None, 'malformed nonce'
raise Malformed('malformed nonce')
message_id = generate_message_id(secret, nonce)
if message_id in message_ids:
return None, 'nonce already used'
try:
message = create_message(message_ids, secret, nonce, comment)
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 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.websocket import parse
from anonstream.utils.websocket import parse_websocket_data
async def websocket_outbound(queue):
payload = {
@ -23,28 +23,33 @@ async def websocket_outbound(queue):
async def websocket_inbound(queue, connected_websockets, token, secret, chat):
while True:
receipt = await websocket.receive_json()
receipt, error = parse(chat.keys(), secret, receipt)
if error is not None:
try:
message_id, nonce, comment = parse_websocket_data(chat.keys(), secret, receipt)
except Malformed as e:
error , *_ = e.args
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(),
}
try:
markup = await add_chat_message(
chat,
connected_websockets,
token,
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)
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
else:
user = generate_user(token, broadcaster, timestamp)
current_app.users[token] = user
return await f(user, *args, **kwargs)
return wrapper

ファイルの表示

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