diff --git a/anonstream/__init__.py b/anonstream/__init__.py index dd86639..98afe40 100644 --- a/anonstream/__init__.py +++ b/anonstream/__init__.py @@ -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']) diff --git a/anonstream/chat.py b/anonstream/chat.py index 68726dc..a28ec41 100644 --- a/anonstream/chat.py +++ b/anonstream/chat.py @@ -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, } ) diff --git a/anonstream/routes.py b/anonstream/routes.py deleted file mode 100644 index a496ed8..0000000 --- a/anonstream/routes.py +++ /dev/null @@ -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 diff --git a/anonstream/routes/__init__.py b/anonstream/routes/__init__.py new file mode 100644 index 0000000..4f49ed1 --- /dev/null +++ b/anonstream/routes/__init__.py @@ -0,0 +1,3 @@ +import anonstream.routes.core +import anonstream.routes.websocket +import anonstream.routes.nojs diff --git a/anonstream/routes/core.py b/anonstream/routes/core.py new file mode 100644 index 0000000..419580c --- /dev/null +++ b/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')) diff --git a/anonstream/routes/nojs.py b/anonstream/routes/nojs.py new file mode 100644 index 0000000..da8aacd --- /dev/null +++ b/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 diff --git a/anonstream/routes/websocket.py b/anonstream/routes/websocket.py new file mode 100644 index 0000000..76585e3 --- /dev/null +++ b/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) diff --git a/anonstream/routes/wrappers.py b/anonstream/routes/wrappers.py new file mode 100644 index 0000000..698ab0a --- /dev/null +++ b/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'
{hint}
' + if request.authorization is None else + 'Wrong username or password. Refresh the page to try again.
' + f'{hint}
' + ) + 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 diff --git a/anonstream/static/anonstream.js b/anonstream/static/anonstream.js index 54cd988..a7bd6a8 100644 --- a/anonstream/static/anonstream.js +++ b/anonstream/static/anonstream.js @@ -2,6 +2,7 @@ const token = document.querySelector("body").dataset.token; /* insert js-only markup */ +const jsmarkup_style = '' const jsmarkup_info = ''; const jsmarkup_info_title = '