From 7dbcd43f3031819b5667b1eaea7633fd041e91cb Mon Sep 17 00:00:00 2001 From: n9k Date: Thu, 17 Feb 2022 00:20:51 +0000 Subject: [PATCH] Logicaler project structure, see rest of commit message Incoming requests are handled in anonstream/routes/. Route handlers mainly depend on files in anonstream/, which in turn depend on files in anonstream/helpers/ and anonstream/utils/. Utils are pure functions and helpers are almost pure functions; they don't mutate state but they do depend on the global app config. --- anonstream/__init__.py | 2 +- anonstream/chat.py | 8 ++--- anonstream/helpers/chat.py | 9 ++++++ anonstream/helpers/user.py | 54 ++++++++++++++++++++++++++++++++ anonstream/routes/nojs.py | 4 +-- anonstream/routes/websocket.py | 3 -- anonstream/routes/wrappers.py | 5 +-- anonstream/{users.py => user.py} | 32 +++---------------- anonstream/utils/chat.py | 4 --- anonstream/utils/user.py | 14 +++++++++ anonstream/utils/users.py | 35 --------------------- anonstream/websocket.py | 17 ++++++---- 12 files changed, 103 insertions(+), 84 deletions(-) create mode 100644 anonstream/helpers/chat.py create mode 100644 anonstream/helpers/user.py rename anonstream/{users.py => user.py} (56%) create mode 100644 anonstream/utils/user.py delete mode 100644 anonstream/utils/users.py diff --git a/anonstream/__init__.py b/anonstream/__init__.py index 98afe40..7e22a47 100644 --- a/anonstream/__init__.py +++ b/anonstream/__init__.py @@ -5,7 +5,7 @@ from collections import OrderedDict from quart import Quart from werkzeug.security import generate_password_hash -from anonstream.utils.users import generate_token +from anonstream.utils.user import generate_token from anonstream.segments import DirectoryCache async def create_app(): diff --git a/anonstream/chat.py b/anonstream/chat.py index a28ec41..e219a14 100644 --- a/anonstream/chat.py +++ b/anonstream/chat.py @@ -3,8 +3,8 @@ from datetime import datetime from quart import escape -from anonstream.users import users_for_websocket -from anonstream.utils.chat import generate_nonce_hash +from anonstream.user import users_for_websocket +from anonstream.helpers.chat import generate_nonce_hash class Rejected(Exception): pass @@ -13,9 +13,9 @@ async def broadcast(websockets, payload): for queue in websockets: await queue.put(payload) -async def add_chat_message(chat, users, websockets, secret, user, nonce, comment): +async def add_chat_message(chat, users, websockets, user, nonce, comment): # check message - nonce_hash = generate_nonce_hash(secret, nonce) + nonce_hash = generate_nonce_hash(nonce) if nonce_hash in chat['nonce_hashes']: raise Rejected('Discarded suspected duplicate message') if len(comment) == 0: diff --git a/anonstream/helpers/chat.py b/anonstream/helpers/chat.py new file mode 100644 index 0000000..807218f --- /dev/null +++ b/anonstream/helpers/chat.py @@ -0,0 +1,9 @@ +import hashlib + +from quart import current_app + +CONFIG = current_app.config + +def generate_nonce_hash(nonce): + parts = CONFIG['SECRET_KEY'] + b'nonce-hash\0' + nonce.encode() + return hashlib.sha256(parts).digest() diff --git a/anonstream/helpers/user.py b/anonstream/helpers/user.py new file mode 100644 index 0000000..ea6944c --- /dev/null +++ b/anonstream/helpers/user.py @@ -0,0 +1,54 @@ +import hashlib +import base64 +from collections import OrderedDict +from math import inf + +from quart import current_app + +CONFIG = current_app.config + +def generate_token_hash(token): + parts = CONFIG['SECRET_KEY'] + 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(token), + 'broadcaster': broadcaster, + 'name': None, + 'color': '#c7007f', + 'tripcode': None, + 'notices': OrderedDict(), + 'seen': { + 'first': timestamp, + 'last': timestamp, + }, + 'watching_last': -inf, + } + +def get_default_name(user): + return ( + CONFIG['DEFAULT_HOST_NAME'] + if user['broadcaster'] else + CONFIG['DEFAULT_ANON_NAME'] + ) + +def is_watching(timestamp, user): + return user['watching_last'] >= timestamp - 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 - 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) diff --git a/anonstream/routes/nojs.py b/anonstream/routes/nojs.py index da8aacd..9dd6f5d 100644 --- a/anonstream/routes/nojs.py +++ b/anonstream/routes/nojs.py @@ -1,9 +1,10 @@ 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.user import add_notice, pop_notice from anonstream.chat import add_chat_message, Rejected from anonstream.routes.wrappers import with_user_from +from anonstream.helpers.user import get_default_name from anonstream.utils.chat import generate_nonce @current_app.route('/info.html') @@ -52,7 +53,6 @@ async def nojs_submit_message(user): chat=current_app.chat, users=current_app.users, websockets=current_app.websockets, - secret=current_app.config['SECRET_KEY'], user=user, nonce=nonce, comment=comment, diff --git a/anonstream/routes/websocket.py b/anonstream/routes/websocket.py index 76585e3..012c013 100644 --- a/anonstream/routes/websocket.py +++ b/anonstream/routes/websocket.py @@ -15,8 +15,6 @@ async def live(user): 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, @@ -24,7 +22,6 @@ async def live(user): users=current_app.users, connected_websockets=current_app.websockets, user=user, - secret=current_app.config['SECRET_KEY'], ) try: await asyncio.gather(producer, consumer) diff --git a/anonstream/routes/wrappers.py b/anonstream/routes/wrappers.py index 698ab0a..ec1891c 100644 --- a/anonstream/routes/wrappers.py +++ b/anonstream/routes/wrappers.py @@ -4,9 +4,10 @@ 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.user import sunset, user_for_websocket from anonstream.websocket import broadcast -from anonstream.utils.users import generate_token, generate_user +from anonstream.helpers.user import generate_user +from anonstream.utils.user import generate_token def check_auth(context): auth = context.authorization diff --git a/anonstream/users.py b/anonstream/user.py similarity index 56% rename from anonstream/users.py rename to anonstream/user.py index 927f8a1..b7fdbeb 100644 --- a/anonstream/users.py +++ b/anonstream/user.py @@ -4,20 +4,16 @@ 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.helpers.user import is_visible +from anonstream.utils.user import user_for_websocket from anonstream.utils import listmap -def get_default_name(user): - return ( - current_app.config['DEFAULT_HOST_NAME'] - if user['broadcaster'] else - current_app.config['DEFAULT_ANON_NAME'] - ) +CONFIG = current_app.config 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']: + if len(user['notices']) > CONFIG['MAX_NOTICES']: user['notices'].popitem(last=False) return notice_id @@ -42,31 +38,13 @@ def users_for_websocket(timestamp, messages, users): 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']: + if timestamp - last_checkup < CONFIG['USER_CHECKUP_PERIOD']: return [] to_delete = [] diff --git a/anonstream/utils/chat.py b/anonstream/utils/chat.py index 1ec62c2..b25adb2 100644 --- a/anonstream/utils/chat.py +++ b/anonstream/utils/chat.py @@ -8,10 +8,6 @@ class NonceReuse(Exception): def generate_nonce(): return secrets.token_urlsafe(16) -def generate_nonce_hash(secret, nonce): - parts = secret + b'nonce-hash\0' + nonce.encode() - return hashlib.sha256(parts).digest() - def message_for_websocket(users, message): message_keys = ('id', 'date', 'time_minutes', 'time_seconds', 'markup') user_keys = ('token_hash',) diff --git a/anonstream/utils/user.py b/anonstream/utils/user.py new file mode 100644 index 0000000..9ab8fb7 --- /dev/null +++ b/anonstream/utils/user.py @@ -0,0 +1,14 @@ +import base64 +import hashlib +import secrets +from collections import OrderedDict +from math import inf + +def generate_token(): + return secrets.token_hex(16) + +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} diff --git a/anonstream/utils/users.py b/anonstream/utils/users.py deleted file mode 100644 index 6a1db2a..0000000 --- a/anonstream/utils/users.py +++ /dev/null @@ -1,35 +0,0 @@ -import base64 -import hashlib -import secrets -from collections import OrderedDict -from math import inf - -def generate_token(): - return secrets.token_hex(16) - -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} diff --git a/anonstream/websocket.py b/anonstream/websocket.py index 860f5ba..e9d998b 100644 --- a/anonstream/websocket.py +++ b/anonstream/websocket.py @@ -1,16 +1,19 @@ import asyncio -from quart import websocket +from quart import current_app, websocket from anonstream.stream import get_stream_title, get_stream_uptime from anonstream.chat import broadcast, add_chat_message, Rejected -from anonstream.users import is_present, users_for_websocket, see +from anonstream.user import users_for_websocket, see from anonstream.wrappers import with_first_argument +from anonstream.helpers.user import is_present 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, messages, users, default_host_name, default_anon_name): +CONFIG = current_app.config + +async def websocket_outbound(queue, messages, users): payload = { 'type': 'init', 'nonce': generate_nonce(), @@ -21,14 +24,17 @@ async def websocket_outbound(queue, messages, users, default_host_name, default_ messages, ), 'users': users_for_websocket(messages, users), - 'default': {True: default_host_name, False: default_anon_name}, + 'default': { + True: CONFIG['DEFAULT_HOST_NAME'], + False: CONFIG['DEFAULT_ANON_NAME'], + }, } await websocket.send_json(payload) while True: payload = await queue.get() await websocket.send_json(payload) -async def websocket_inbound(queue, chat, users, connected_websockets, user, secret): +async def websocket_inbound(queue, chat, users, connected_websockets, user): while True: receipt = await websocket.receive_json() see(user) @@ -46,7 +52,6 @@ async def websocket_inbound(queue, chat, users, connected_websockets, user, secr chat, users, connected_websockets, - secret, user, nonce, comment,