From 694c6a49959f21f79b7a0bdf1a4ec082d19cbd0a Mon Sep 17 00:00:00 2001 From: n9k Date: Tue, 15 Feb 2022 10:11:53 +0000 Subject: [PATCH] Nojs comment submission, notify for rejected comments Fix with-user wrapper (wasn't collecting users) --- anonstream/__init__.py | 11 ++-- anonstream/chat.py | 31 +++++++++- anonstream/routes.py | 49 ++++++++++++++- anonstream/static/anonstream.js | 35 ++++++----- anonstream/static/style.css | 8 +-- anonstream/templates/nojs_form.html | 96 ++++++++++++++++++----------- anonstream/users.py | 15 +++++ anonstream/utils/chat.py | 9 +++ anonstream/utils/users.py | 2 + anonstream/utils/websocket.py | 26 ++++---- anonstream/websocket.py | 47 +++++++------- anonstream/wrappers.py | 1 + config.toml | 15 ++++- 13 files changed, 245 insertions(+), 100 deletions(-) diff --git a/anonstream/__init__.py b/anonstream/__init__.py index 6a45237..dd86639 100644 --- a/anonstream/__init__.py +++ b/anonstream/__init__.py @@ -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 diff --git a/anonstream/chat.py b/anonstream/chat.py index 3a6cd09..68726dc 100644 --- a/anonstream/chat.py +++ b/anonstream/chat.py @@ -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 diff --git a/anonstream/routes.py b/anonstream/routes.py index 1dfdb06..a496ed8 100644 --- a/anonstream/routes.py +++ b/anonstream/routes.py @@ -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 diff --git a/anonstream/static/anonstream.js b/anonstream/static/anonstream.js index f45427c..cf392b7 100644 --- a/anonstream/static/anonstream.js +++ b/anonstream/static/anonstream.js @@ -8,7 +8,7 @@ const jsmarkup_chat_messages = ''; const jsmarkup_chat_form = `\
- +
Not connected to chat @@ -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", ": "); - 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)); }); diff --git a/anonstream/static/style.css b/anonstream/static/style.css index 108581d..1685d60 100644 --- a/anonstream/static/style.css +++ b/anonstream/static/style.css @@ -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; } diff --git a/anonstream/templates/nojs_form.html b/anonstream/templates/nojs_form.html index 7d50317..43c63f2 100644 --- a/anonstream/templates/nojs_form.html +++ b/anonstream/templates/nojs_form.html @@ -4,6 +4,7 @@ - - -

No notice

- Click to dismiss -
- - - - - - + +
+
+ {% if notice != none %} + +

{{ notice }}

+ Click to dismiss +
+ {% endif %} + + + + + -
- + @@ -153,18 +174,19 @@
{% if user.tripcode == none %} - + (no tripcode) + {% else %} - - + +
(cleared)
- + {% endif %}
-
- + +
-
Return to chat
+
Return to chat
diff --git a/anonstream/users.py b/anonstream/users.py index a0c8103..f27810d 100644 --- a/anonstream/users.py +++ b/anonstream/users.py @@ -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 diff --git a/anonstream/utils/chat.py b/anonstream/utils/chat.py index af7cee1..300664b 100644 --- a/anonstream/utils/chat.py +++ b/anonstream/utils/chat.py @@ -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 diff --git a/anonstream/utils/users.py b/anonstream/utils/users.py index fd58d58..26c4b0a 100644 --- a/anonstream/utils/users.py +++ b/anonstream/utils/users.py @@ -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, diff --git a/anonstream/utils/websocket.py b/anonstream/utils/websocket.py index 7c5ebb0..d250dce 100644 --- a/anonstream/utils/websocket.py +++ b/anonstream/utils/websocket.py @@ -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 diff --git a/anonstream/websocket.py b/anonstream/websocket.py index 9171e78..807a757 100644 --- a/anonstream/websocket.py +++ b/anonstream/websocket.py @@ -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) diff --git a/anonstream/wrappers.py b/anonstream/wrappers.py index 09c8c94..86454f3 100644 --- a/anonstream/wrappers.py +++ b/anonstream/wrappers.py @@ -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 diff --git a/config.toml b/config.toml index 0339443..a7da9cf 100644 --- a/config.toml +++ b/config.toml @@ -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