From fc4a528b042a324d8490c20ce6815efb6480fe31 Mon Sep 17 00:00:00 2001 From: n9k Date: Fri, 17 Jun 2022 00:48:04 +0000 Subject: [PATCH 01/19] anonstream/__main__.py: tidy magic numbers for real --- anonstream/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/anonstream/__main__.py b/anonstream/__main__.py index 212bdce..67b5792 100644 --- a/anonstream/__main__.py +++ b/anonstream/__main__.py @@ -31,7 +31,7 @@ parser = argparse.ArgumentParser( parser.add_argument( '--config', '-c', metavar='FILE', - default=os.environ.get('ANONSTREAM_CONFIG', 'config.toml'), + default=os.environ.get('ANONSTREAM_CONFIG', DEFAULT_CONFIG), help=( 'location of config.toml ' f'(default: $ANONSTREAM_CONFIG or {want_rel(DEFAULT_CONFIG)})' From 893c4273b069792b7f5e46fe966d3756a9c8410c Mon Sep 17 00:00:00 2001 From: n9k Date: Fri, 17 Jun 2022 01:05:36 +0000 Subject: [PATCH 02/19] Licence headers formatting for real --- anonstream/static/anonstream.js | 2 +- anonstream/static/settings.svg | 2 +- anonstream/static/style.css | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/anonstream/static/anonstream.js b/anonstream/static/anonstream.js index 1c68be4..3873e6f 100644 --- a/anonstream/static/anonstream.js +++ b/anonstream/static/anonstream.js @@ -1,5 +1,5 @@ /** - * SPDX-FileCopyrightText: 2022 n9k [https://git.076.ne.jp/ninya9k] + * SPDX-FileCopyrightText: 2022 n9k * SPDX-License-Identifier: AGPL-3.0-or-later **/ diff --git a/anonstream/static/settings.svg b/anonstream/static/settings.svg index 8859dc7..929efff 100644 --- a/anonstream/static/settings.svg +++ b/anonstream/static/settings.svg @@ -1,6 +1,6 @@ diff --git a/anonstream/static/style.css b/anonstream/static/style.css index a2a379b..9ee1d60 100644 --- a/anonstream/static/style.css +++ b/anonstream/static/style.css @@ -1,5 +1,5 @@ /** - * SPDX-FileCopyrightText: 2022 n9k [https://git.076.ne.jp/ninya9k] + * SPDX-FileCopyrightText: 2022 n9k * SPDX-License-Identifier: AGPL-3.0-or-later **/ From 0b78a7911134a6c98664a7a2629871d2700b37b9 Mon Sep 17 00:00:00 2001 From: n9k Date: Sun, 19 Jun 2022 07:53:31 +0000 Subject: [PATCH 03/19] Use single quotes --- anonstream/__init__.py | 4 ++-- anonstream/routes/wrappers.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/anonstream/__init__.py b/anonstream/__init__.py index 58b318d..69e85db 100644 --- a/anonstream/__init__.py +++ b/anonstream/__init__.py @@ -23,8 +23,8 @@ def create_app(toml_config): # Compress some responses compress.init_app(app) app.config.update({ - "COMPRESS_MIN_SIZE": 2048, - "COMPRESS_LEVEL": 9, + 'COMPRESS_MIN_SIZE': 2048, + 'COMPRESS_LEVEL': 9, }) # Global state: messages, users, captchas diff --git a/anonstream/routes/wrappers.py b/anonstream/routes/wrappers.py index c5ac817..59b006f 100644 --- a/anonstream/routes/wrappers.py +++ b/anonstream/routes/wrappers.py @@ -35,9 +35,9 @@ def check_auth(context): auth = context.authorization return ( auth is not None - and auth.type == "basic" - and auth.username == CONFIG["AUTH_USERNAME"] - and check_password_hash(CONFIG["AUTH_PWHASH"], auth.password) + and auth.type == 'basic' + and auth.username == CONFIG['AUTH_USERNAME'] + and check_password_hash(CONFIG['AUTH_PWHASH'], auth.password) ) def auth_required(f): From 1d5b4462910e1d7f8846cf6e9e406471520d8fd3 Mon Sep 17 00:00:00 2001 From: n9k Date: Sun, 19 Jun 2022 08:10:23 +0000 Subject: [PATCH 04/19] Track the last time users were sent chat messages --- anonstream/helpers/user.py | 1 + anonstream/routes/core.py | 4 ++-- anonstream/routes/nojs.py | 3 ++- anonstream/routes/websocket.py | 5 +++-- anonstream/routes/wrappers.py | 4 ++-- anonstream/user.py | 13 ++++++++++--- anonstream/websocket.py | 3 ++- anonstream/wrappers.py | 13 +++++++++---- 8 files changed, 31 insertions(+), 15 deletions(-) diff --git a/anonstream/helpers/user.py b/anonstream/helpers/user.py index 072230c..41b1023 100644 --- a/anonstream/helpers/user.py +++ b/anonstream/helpers/user.py @@ -44,6 +44,7 @@ def generate_user(timestamp, token, broadcaster, presence): 'seen': timestamp, 'watching': -inf, 'eyes': -inf, + 'reading': -inf, }, 'presence': presence, 'linespan': deque(), diff --git a/anonstream/routes/core.py b/anonstream/routes/core.py index 92425be..ad15dbe 100644 --- a/anonstream/routes/core.py +++ b/anonstream/routes/core.py @@ -9,7 +9,7 @@ from werkzeug.exceptions import TooManyRequests from anonstream.captcha import get_captcha_image from anonstream.segments import segments, StopSendingSegments from anonstream.stream import is_online, get_stream_uptime -from anonstream.user import watched, create_eyes, renew_eyes, EyesException, RatelimitedEyes +from anonstream.user import watching, create_eyes, renew_eyes, EyesException, RatelimitedEyes from anonstream.routes.wrappers import with_user_from, auth_required from anonstream.utils.security import generate_csp @@ -42,7 +42,7 @@ async def stream(user): except EyesException as e: raise StopSendingSegments(f'eyes {eyes_id} not allowed: {e!r}') from e print(f'{uri}: {eyes_id}~{user["token"]}') - watched(user) + watching(user) generator = segments(segment_read_hook, token=user['token']) response = await make_response(generator) diff --git a/anonstream/routes/nojs.py b/anonstream/routes/nojs.py index 0a6c836..1635e69 100644 --- a/anonstream/routes/nojs.py +++ b/anonstream/routes/nojs.py @@ -6,7 +6,7 @@ from quart import current_app, request, render_template, redirect, url_for, esca from anonstream.captcha import get_random_captcha_digest_for from anonstream.chat import add_chat_message, Rejected from anonstream.stream import is_online, get_stream_title, get_stream_uptime_and_viewership -from anonstream.user import add_state, pop_state, try_change_appearance, update_presence, get_users_by_presence, Presence, verify, deverify, BadCaptcha +from anonstream.user import add_state, pop_state, try_change_appearance, update_presence, get_users_by_presence, Presence, verify, deverify, BadCaptcha, reading from anonstream.routes.wrappers import with_user_from, render_template_with_etag from anonstream.helpers.chat import get_scrollback from anonstream.helpers.user import get_default_name @@ -46,6 +46,7 @@ async def nojs_info(user): @current_app.route('/chat/messages.html') @with_user_from(request) async def nojs_chat_messages(user): + reading(user) return await render_template_with_etag( 'nojs_chat_messages.html', {'csp': generate_csp()}, diff --git a/anonstream/routes/websocket.py b/anonstream/routes/websocket.py index 12eafd4..ab4927a 100644 --- a/anonstream/routes/websocket.py +++ b/anonstream/routes/websocket.py @@ -7,15 +7,16 @@ from math import inf from quart import current_app, websocket -from anonstream.user import see +from anonstream.user import see, reading 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(maxsize=0) + queue = asyncio.Queue() user['websockets'][queue] = -inf + reading(user) producer = websocket_outbound(queue, user) consumer = websocket_inbound(queue, user) diff --git a/anonstream/routes/wrappers.py b/anonstream/routes/wrappers.py index 59b006f..9bc0da9 100644 --- a/anonstream/routes/wrappers.py +++ b/anonstream/routes/wrappers.py @@ -5,7 +5,6 @@ import hashlib import hmac import re import string -import time from functools import wraps from quart import current_app, request, abort, make_response, render_template, request @@ -15,6 +14,7 @@ from anonstream.broadcast import broadcast from anonstream.user import see from anonstream.helpers.user import generate_user from anonstream.utils.user import generate_token, Presence +from anonstream.wrappers import get_timestamp CONFIG = current_app.config MESSAGES = current_app.messages @@ -68,7 +68,7 @@ def with_user_from(context): def with_user_from_context(f): @wraps(f) async def wrapper(*args, **kwargs): - timestamp = int(time.time()) + timestamp = get_timestamp() # Check if broadcaster broadcaster = check_auth(context) diff --git a/anonstream/user.py b/anonstream/user.py index 914f48f..8530810 100644 --- a/anonstream/user.py +++ b/anonstream/user.py @@ -7,7 +7,7 @@ from math import inf from quart import current_app -from anonstream.wrappers import try_except_log, with_timestamp +from anonstream.wrappers import try_except_log, with_timestamp, get_timestamp from anonstream.helpers.user import get_default_name, get_presence, Presence from anonstream.helpers.captcha import check_captcha_digest, Answer from anonstream.helpers.tripcode import generate_tripcode @@ -136,11 +136,18 @@ def delete_tripcode(user): def see(timestamp, user): user['last']['seen'] = timestamp -@with_timestamp() -def watched(timestamp, user): +def watching(user, timestamp=None): + if timestamp is None: + timestamp = get_timestamp() user['last']['seen'] = timestamp user['last']['watching'] = timestamp +def reading(user, timestamp=None): + if timestamp is None: + timestamp = get_timestamp() + user['last']['seen'] = timestamp + user['last']['reading'] = timestamp + @with_timestamp() def get_all_users_for_websocket(timestamp): return { diff --git a/anonstream/websocket.py b/anonstream/websocket.py index 4a48132..0c21382 100644 --- a/anonstream/websocket.py +++ b/anonstream/websocket.py @@ -9,7 +9,7 @@ from quart import current_app, websocket from anonstream.stream import get_stream_title, get_stream_uptime_and_viewership from anonstream.captcha import get_random_captcha_digest_for from anonstream.chat import get_all_messages_for_websocket, add_chat_message, Rejected -from anonstream.user import get_all_users_for_websocket, see, verify, deverify, BadCaptcha, try_change_appearance +from anonstream.user import get_all_users_for_websocket, see, reading, verify, deverify, BadCaptcha, try_change_appearance from anonstream.wrappers import with_timestamp from anonstream.utils.chat import generate_nonce from anonstream.utils.websocket import parse_websocket_data, Malformed, WS @@ -75,6 +75,7 @@ async def websocket_inbound(queue, user): @with_timestamp() def handle_inbound_pong(timestamp, queue, user): print(f'[pong] {user["token"]}') + reading(user, timestamp=timestamp) user['websockets'][queue] = timestamp return None diff --git a/anonstream/wrappers.py b/anonstream/wrappers.py index 50535fb..e0552c3 100644 --- a/anonstream/wrappers.py +++ b/anonstream/wrappers.py @@ -16,13 +16,18 @@ def with_function_call(fn, *fn_args, **fn_kwargs): def with_constant(x): return with_function_call(lambda: x) -def with_timestamp(monotonic=False, precise=False): +def get_timestamp(monotonic=False, precise=False): n = 1_000_000_000 if monotonic: - fn = precise and time.monotonic or (lambda: time.monotonic_ns() // n) + timestamp = precise and time.monotonic() or time.monotonic_ns() // n else: - fn = precise and time.time or (lambda: time.time_ns() // n) - return with_function_call(fn) + timestamp = precise and time.time() or time.time_ns() // n + return timestamp + +def with_timestamp(monotonic=False, precise=False): + def get_timestamp_specific(): + return get_timestamp(monotonic=monotonic, precise=precise) + return with_function_call(get_timestamp_specific) def try_except_log(errors, exception_class): def try_except_log_specific(f): From 1581e6ac892c0abbe0b785e7b76b730e9c4c9c73 Mon Sep 17 00:00:00 2001 From: n9k Date: Sun, 19 Jun 2022 08:16:10 +0000 Subject: [PATCH 05/19] Minor logic formatting --- anonstream/user.py | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/anonstream/user.py b/anonstream/user.py index 8530810..d9f4eea 100644 --- a/anonstream/user.py +++ b/anonstream/user.py @@ -174,19 +174,16 @@ def verify(user, digest, answer): @with_timestamp() def deverify(timestamp, user): - if not user['verified']: - return - - n_user_messages = 0 - for message in reversed(MESSAGES): - message_sent_ago = timestamp - message['timestamp'] - if message_sent_ago >= CONFIG['FLOOD_MESSAGE_DURATION']: - break - elif message['token'] == user['token']: - n_user_messages += 1 - - if n_user_messages >= CONFIG['FLOOD_MESSAGE_THRESHOLD']: - user['verified'] = False + if user['verified']: + n_user_messages = 0 + for message in reversed(MESSAGES): + message_sent_ago = timestamp - message['timestamp'] + if message_sent_ago >= CONFIG['FLOOD_MESSAGE_DURATION']: + break + elif message['token'] == user['token']: + n_user_messages += 1 + if n_user_messages >= CONFIG['FLOOD_MESSAGE_THRESHOLD']: + user['verified'] = False def _update_presence(timestamp, user): old, user['presence'] = user['presence'], get_presence(timestamp, user) From 90e1e2099a1a2f3ac64b5635fcf47c5b62004065 Mon Sep 17 00:00:00 2001 From: n9k Date: Sun, 19 Jun 2022 08:26:38 +0000 Subject: [PATCH 06/19] Manual static folder --- anonstream/__init__.py | 2 +- anonstream/routes/core.py | 12 ++++++++++-- anonstream/routes/wrappers.py | 25 +++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/anonstream/__init__.py b/anonstream/__init__.py index 69e85db..09bb394 100644 --- a/anonstream/__init__.py +++ b/anonstream/__init__.py @@ -12,7 +12,7 @@ from anonstream.quart import Quart compress = Compress() def create_app(toml_config): - app = Quart('anonstream') + app = Quart('anonstream', static_folder=None) app.jinja_options['trim_blocks'] = True app.jinja_options['lstrip_blocks'] = True diff --git a/anonstream/routes/core.py b/anonstream/routes/core.py index ad15dbe..00ef969 100644 --- a/anonstream/routes/core.py +++ b/anonstream/routes/core.py @@ -3,16 +3,18 @@ import math -from quart import current_app, request, render_template, abort, make_response, redirect, url_for, abort +from quart import current_app, request, render_template, abort, make_response, redirect, url_for, abort, send_from_directory from werkzeug.exceptions import TooManyRequests from anonstream.captcha import get_captcha_image from anonstream.segments import segments, StopSendingSegments from anonstream.stream import is_online, get_stream_uptime from anonstream.user import watching, create_eyes, renew_eyes, EyesException, RatelimitedEyes -from anonstream.routes.wrappers import with_user_from, auth_required +from anonstream.routes.wrappers import with_user_from, auth_required, clean_cache_headers from anonstream.utils.security import generate_csp +STATIC_DIRECTORY = current_app.root_path / 'static' + @current_app.route('/') @with_user_from(request) async def home(user): @@ -64,3 +66,9 @@ async def captcha(user): return abort(410) else: return image, {'Content-Type': 'image/jpeg'} + +@current_app.route('/static/') +@with_user_from(request) +@clean_cache_headers +async def static(user, filename): + return await send_from_directory(STATIC_DIRECTORY, filename) diff --git a/anonstream/routes/wrappers.py b/anonstream/routes/wrappers.py index 9bc0da9..4fd4ff6 100644 --- a/anonstream/routes/wrappers.py +++ b/anonstream/routes/wrappers.py @@ -127,3 +127,28 @@ async def render_template_with_etag(template, deferred_kwargs, **kwargs): **kwargs, ) return rendered_template, {'ETag': etag} + +def clean_cache_headers(f): + @wraps(f) + async def wrapper(*args, **kwargs): + response = await f(*args, **kwargs) + + # Remove Last-Modified + try: + response.headers.pop('Last-Modified') + except KeyError: + pass + + # Obfuscate ETag + try: + original_etag = response.headers['ETag'] + except KeyError: + pass + else: + parts = CONFIG['SECRET_KEY'] + b'etag\0' + original_etag.encode() + tag = hashlib.sha256(parts).hexdigest() + response.headers['ETag'] = f'"{tag}"' + + return response + + return wrapper From 22c84bc2304aaae21cd3d51e217ab006473e2890 Mon Sep 17 00:00:00 2001 From: n9k Date: Sun, 19 Jun 2022 08:51:42 +0000 Subject: [PATCH 07/19] Give timestamp to route handlers --- anonstream/routes/core.py | 8 ++++---- anonstream/routes/nojs.py | 18 +++++++++--------- anonstream/routes/websocket.py | 4 ++-- anonstream/routes/wrappers.py | 2 +- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/anonstream/routes/core.py b/anonstream/routes/core.py index 00ef969..a3dfc14 100644 --- a/anonstream/routes/core.py +++ b/anonstream/routes/core.py @@ -17,7 +17,7 @@ STATIC_DIRECTORY = current_app.root_path / 'static' @current_app.route('/') @with_user_from(request) -async def home(user): +async def home(timestamp, user): return await render_template( 'home.html', csp=generate_csp(), @@ -26,7 +26,7 @@ async def home(user): @current_app.route('/stream.mp4') @with_user_from(request) -async def stream(user): +async def stream(timestamp, user): if not is_online(): return abort(404) @@ -59,7 +59,7 @@ async def login(): @current_app.route('/captcha.jpg') @with_user_from(request) -async def captcha(user): +async def captcha(timestamp, user): digest = request.args.get('digest', '') image = get_captcha_image(digest) if image is None: @@ -70,5 +70,5 @@ async def captcha(user): @current_app.route('/static/') @with_user_from(request) @clean_cache_headers -async def static(user, filename): +async def static(timestamp, user, filename): return await send_from_directory(STATIC_DIRECTORY, filename) diff --git a/anonstream/routes/nojs.py b/anonstream/routes/nojs.py index 1635e69..c0f7cd8 100644 --- a/anonstream/routes/nojs.py +++ b/anonstream/routes/nojs.py @@ -19,7 +19,7 @@ USERS_BY_TOKEN = current_app.users_by_token @current_app.route('/stream.html') @with_user_from(request) -async def nojs_stream(user): +async def nojs_stream(timestamp, user): return await render_template( 'nojs_stream.html', csp=generate_csp(), @@ -29,7 +29,7 @@ async def nojs_stream(user): @current_app.route('/info.html') @with_user_from(request) -async def nojs_info(user): +async def nojs_info(timestamp, user): update_presence(user) uptime, viewership = get_stream_uptime_and_viewership() return await render_template( @@ -45,7 +45,7 @@ async def nojs_info(user): @current_app.route('/chat/messages.html') @with_user_from(request) -async def nojs_chat_messages(user): +async def nojs_chat_messages(timestamp, user): reading(user) return await render_template_with_etag( 'nojs_chat_messages.html', @@ -60,12 +60,12 @@ async def nojs_chat_messages(user): @current_app.route('/chat/messages') @with_user_from(request) -async def nojs_chat_messages_redirect(user): +async def nojs_chat_messages_redirect(timestamp, user): return redirect(url_for('nojs_chat_messages', token=user['token'], _anchor='end')) @current_app.route('/chat/users.html') @with_user_from(request) -async def nojs_chat_users(user): +async def nojs_chat_users(timestamp, user): users_by_presence = get_users_by_presence() return await render_template_with_etag( 'nojs_chat_users.html', @@ -80,7 +80,7 @@ async def nojs_chat_users(user): @current_app.route('/chat/form.html') @with_user_from(request) -async def nojs_chat_form(user): +async def nojs_chat_form(timestamp, user): state_id = request.args.get('state', type=int) state = pop_state(user, state_id) prefer_chat_form = request.args.get('landing') != 'appearance' @@ -100,7 +100,7 @@ async def nojs_chat_form(user): @current_app.post('/chat/form') @with_user_from(request) -async def nojs_chat_form_redirect(user): +async def nojs_chat_form_redirect(timestamp, user): comment = (await request.form).get('comment', '') if comment: state_id = add_state( @@ -113,7 +113,7 @@ async def nojs_chat_form_redirect(user): @current_app.post('/chat/message') @with_user_from(request) -async def nojs_submit_message(user): +async def nojs_submit_message(timestamp, user): form = await request.form comment = form.get('comment', '') @@ -160,7 +160,7 @@ async def nojs_submit_message(user): @current_app.post('/chat/appearance') @with_user_from(request) -async def nojs_submit_appearance(user): +async def nojs_submit_appearance(timestamp, user): form = await request.form # Collect form data diff --git a/anonstream/routes/websocket.py b/anonstream/routes/websocket.py index ab4927a..f342b0d 100644 --- a/anonstream/routes/websocket.py +++ b/anonstream/routes/websocket.py @@ -13,10 +13,10 @@ from anonstream.routes.wrappers import with_user_from @current_app.websocket('/live') @with_user_from(websocket) -async def live(user): +async def live(timestamp, user): queue = asyncio.Queue() user['websockets'][queue] = -inf - reading(user) + reading(user, timestamp=timestamp) producer = websocket_outbound(queue, user) consumer = websocket_inbound(queue, user) diff --git a/anonstream/routes/wrappers.py b/anonstream/routes/wrappers.py index 4fd4ff6..a3e21cd 100644 --- a/anonstream/routes/wrappers.py +++ b/anonstream/routes/wrappers.py @@ -104,7 +104,7 @@ def with_user_from(context): USERS_UPDATE_BUFFER.add(token) # Set cookie - response = await f(user, *args, **kwargs) + response = await f(timestamp, user, *args, **kwargs) if context.cookies.get('token') != token: response = await make_response(response) response.headers['Set-Cookie'] = f'token={token}; path=/' From 46f9b0ec08ea91804460e9ffc6d02b222cb7a475 Mon Sep 17 00:00:00 2001 From: n9k Date: Sun, 19 Jun 2022 09:24:28 +0000 Subject: [PATCH 08/19] Reset websocket aliveness timer on first connecting This should eliminate the possibilty of the websocket-closing background task closing a newly opened websocket that hasn't yet ponged our ping (if we have even sent a ping yet). --- anonstream/routes/websocket.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/anonstream/routes/websocket.py b/anonstream/routes/websocket.py index f342b0d..96dbb84 100644 --- a/anonstream/routes/websocket.py +++ b/anonstream/routes/websocket.py @@ -2,9 +2,6 @@ # SPDX-License-Identifier: AGPL-3.0-or-later import asyncio - -from math import inf - from quart import current_app, websocket from anonstream.user import see, reading @@ -15,7 +12,7 @@ from anonstream.routes.wrappers import with_user_from @with_user_from(websocket) async def live(timestamp, user): queue = asyncio.Queue() - user['websockets'][queue] = -inf + user['websockets'][queue] = timestamp reading(user, timestamp=timestamp) producer = websocket_outbound(queue, user) From a41f0d4f14ac76f2326fdb5b8fa5f39edb2bb7f1 Mon Sep 17 00:00:00 2001 From: n9k Date: Sun, 19 Jun 2022 08:52:46 +0000 Subject: [PATCH 09/19] Escape disallowed cookie characters --- anonstream/routes/wrappers.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/anonstream/routes/wrappers.py b/anonstream/routes/wrappers.py index a3e21cd..9a93056 100644 --- a/anonstream/routes/wrappers.py +++ b/anonstream/routes/wrappers.py @@ -6,6 +6,7 @@ import hmac import re import string from functools import wraps +from urllib.parse import quote, unquote from quart import current_app, request, abort, make_response, render_template, request from werkzeug.security import check_password_hash @@ -31,6 +32,12 @@ TOKEN_ALPHABET = ( ) RE_TOKEN = re.compile(r'[%s]{1,256}' % re.escape(TOKEN_ALPHABET)) +def try_unquote(string): + if string is None: + return None + else: + return unquote(string) + def check_auth(context): auth = context.authorization return ( @@ -77,7 +84,7 @@ def with_user_from(context): else: token = ( context.args.get('token') - or context.cookies.get('token') + or try_unquote(context.cookies.get('token')) or generate_token() ) if hmac.compare_digest(token, CONFIG['AUTH_TOKEN']): @@ -105,9 +112,9 @@ def with_user_from(context): # Set cookie response = await f(timestamp, user, *args, **kwargs) - if context.cookies.get('token') != token: + if try_unquote(context.cookies.get('token')) != token: response = await make_response(response) - response.headers['Set-Cookie'] = f'token={token}; path=/' + response.headers['Set-Cookie'] = f'token={quote(token)}; path=/' return response return wrapper From 6ae87be229db8b0f6addee4e2cdbc0836f1bc638 Mon Sep 17 00:00:00 2001 From: n9k Date: Tue, 21 Jun 2022 06:23:13 +0000 Subject: [PATCH 10/19] anonstream/__main__.py: disable Server header --- anonstream/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/anonstream/__main__.py b/anonstream/__main__.py index 67b5792..92c2a46 100644 --- a/anonstream/__main__.py +++ b/anonstream/__main__.py @@ -48,4 +48,4 @@ args = parser.parse_args() with open(args.config) as fp: config = toml.load(fp) app = create_app(config) -uvicorn.run(app, port=args.port) +uvicorn.run(app, port=args.port, server_header=False) From 4c5faf7dbaaf44344ff6403eb61f9445f70c93d2 Mon Sep 17 00:00:00 2001 From: n9k Date: Wed, 22 Jun 2022 04:45:32 +0000 Subject: [PATCH 11/19] Use 303 See Other for {POST|GET}->GET redirects --- anonstream/routes/core.py | 2 +- anonstream/routes/nojs.py | 16 ++++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/anonstream/routes/core.py b/anonstream/routes/core.py index a3dfc14..f6ba9dc 100644 --- a/anonstream/routes/core.py +++ b/anonstream/routes/core.py @@ -55,7 +55,7 @@ async def stream(timestamp, user): @current_app.route('/login') @auth_required async def login(): - return redirect(url_for('home')) + return redirect(url_for('home'), 303) @current_app.route('/captcha.jpg') @with_user_from(request) diff --git a/anonstream/routes/nojs.py b/anonstream/routes/nojs.py index c0f7cd8..0ce8d12 100644 --- a/anonstream/routes/nojs.py +++ b/anonstream/routes/nojs.py @@ -61,7 +61,8 @@ async def nojs_chat_messages(timestamp, user): @current_app.route('/chat/messages') @with_user_from(request) async def nojs_chat_messages_redirect(timestamp, user): - return redirect(url_for('nojs_chat_messages', token=user['token'], _anchor='end')) + url = url_for('nojs_chat_messages', token=user['token'], _anchor='end') + return redirect(url, 303) @current_app.route('/chat/users.html') @with_user_from(request) @@ -109,7 +110,8 @@ async def nojs_chat_form_redirect(timestamp, user): ) else: state_id = None - return redirect(url_for('nojs_chat_form', token=user['token'], state=state_id)) + url = url_for('nojs_chat_form', token=user['token'], state=state_id) + return redirect(url, 303) @current_app.post('/chat/message') @with_user_from(request) @@ -151,12 +153,13 @@ async def nojs_submit_message(timestamp, user): if message_was_added: deverify(user) - return redirect(url_for( + url = url_for( 'nojs_chat_form', token=user['token'], landing='chat', state=state_id, - )) + ) + return redirect(url, 303) @current_app.post('/chat/appearance') @with_user_from(request) @@ -188,9 +191,10 @@ async def nojs_submit_appearance(timestamp, user): notice = 'Changed appearance' state_id = add_state(user, notice=notice, verbose=len(errors) > 1) - return redirect(url_for( + url = url_for( 'nojs_chat_form', token=user['token'], landing='appearance' if errors else 'chat', state=state_id, - )) + ) + return redirect(url, 303) From 9143acafd1c61b7beee868239c23848cce92638a Mon Sep 17 00:00:00 2001 From: n9k Date: Wed, 22 Jun 2022 05:00:43 +0000 Subject: [PATCH 12/19] Access captcha --- anonstream/__init__.py | 3 +- anonstream/access.py | 18 ++++++++ anonstream/config.py | 1 + anonstream/routes/core.py | 64 +++++++++++++++++++++++----- anonstream/routes/wrappers.py | 71 ++++++++++++++++++++----------- anonstream/templates/captcha.html | 60 ++++++++++++++++++++++++++ config.toml | 3 ++ 7 files changed, 182 insertions(+), 38 deletions(-) create mode 100644 anonstream/access.py create mode 100644 anonstream/templates/captcha.html diff --git a/anonstream/__init__.py b/anonstream/__init__.py index 09bb394..1e4a915 100644 --- a/anonstream/__init__.py +++ b/anonstream/__init__.py @@ -38,6 +38,8 @@ def create_app(toml_config): app.captcha_factory = create_captcha_factory(app.config['CAPTCHA_FONTS']) app.captcha_signer = create_captcha_signer(app.config['SECRET_KEY']) + app.failures = {} + # State for tasks app.users_update_buffer = set() app.stream_title = None @@ -77,7 +79,6 @@ def create_app(toml_config): ) app.add_background_task(start_event_server) - # Create routes and background tasks import anonstream.routes import anonstream.tasks diff --git a/anonstream/access.py b/anonstream/access.py new file mode 100644 index 0000000..822acd2 --- /dev/null +++ b/anonstream/access.py @@ -0,0 +1,18 @@ +import time + +from quart import current_app + +FAILURES = current_app.failures + +def add_failure(message): + timestamp = time.time_ns() // 1_000_000 + while timestamp in FAILURES: + timestamp += 1 + FAILURES[timestamp] = message + return timestamp + +def pop_failure(failure_id): + try: + return FAILURES.pop(failure_id) + except KeyError: + return None diff --git a/anonstream/config.py b/anonstream/config.py index 376505d..0c5c580 100644 --- a/anonstream/config.py +++ b/anonstream/config.py @@ -19,6 +19,7 @@ def update_flask_from_toml(toml_config, flask_config): 'AUTH_USERNAME': toml_config['auth']['username'], 'AUTH_PWHASH': auth_pwhash, 'AUTH_TOKEN': generate_token(), + 'ACCESS_CAPTCHA': toml_config['access']['captcha'], }) for flask_section in toml_to_flask_sections(toml_config): flask_config.update(flask_section) diff --git a/anonstream/routes/core.py b/anonstream/routes/core.py index f6ba9dc..73637c5 100644 --- a/anonstream/routes/core.py +++ b/anonstream/routes/core.py @@ -6,23 +6,38 @@ import math from quart import current_app, request, render_template, abort, make_response, redirect, url_for, abort, send_from_directory from werkzeug.exceptions import TooManyRequests -from anonstream.captcha import get_captcha_image +from anonstream.access import add_failure, pop_failure +from anonstream.captcha import get_captcha_image, get_random_captcha_digest from anonstream.segments import segments, StopSendingSegments from anonstream.stream import is_online, get_stream_uptime from anonstream.user import watching, create_eyes, renew_eyes, EyesException, RatelimitedEyes -from anonstream.routes.wrappers import with_user_from, auth_required, clean_cache_headers +from anonstream.routes.wrappers import with_user_from, auth_required, clean_cache_headers, generate_and_add_user +from anonstream.helpers.captcha import check_captcha_digest, Answer from anonstream.utils.security import generate_csp +CAPTCHA_SIGNER = current_app.captcha_signer STATIC_DIRECTORY = current_app.root_path / 'static' @current_app.route('/') -@with_user_from(request) -async def home(timestamp, user): - return await render_template( - 'home.html', - csp=generate_csp(), - user=user, - ) +@with_user_from(request, fallback_to_token=True) +async def home(timestamp, user_or_token): + match user_or_token: + case str() | None: + failure_id = request.args.get('failure', type=int) + response = await render_template( + 'captcha.html', + csp=generate_csp(), + token=user_or_token, + digest=get_random_captcha_digest(), + failure=pop_failure(failure_id), + ) + case dict(): + response = await render_template( + 'home.html', + csp=generate_csp(), + user=user_or_token, + ) + return response @current_app.route('/stream.mp4') @with_user_from(request) @@ -58,8 +73,8 @@ async def login(): return redirect(url_for('home'), 303) @current_app.route('/captcha.jpg') -@with_user_from(request) -async def captcha(timestamp, user): +@with_user_from(request, fallback_to_token=True) +async def captcha(timestamp, user_or_token): digest = request.args.get('digest', '') image = get_captcha_image(digest) if image is None: @@ -67,6 +82,33 @@ async def captcha(timestamp, user): else: return image, {'Content-Type': 'image/jpeg'} +@current_app.post('/access') +@with_user_from(request, fallback_to_token=True) +async def access(timestamp, user_or_token): + match user_or_token: + case str() | None: + token = user_or_token + form = await request.form + digest = form.get('digest', '') + answer = form.get('answer', '') + match check_captcha_digest(CAPTCHA_SIGNER, digest, answer): + case Answer.MISSING: + failure_id = add_failure('Captcha is required') + case Answer.BAD: + failure_id = add_failure('Captcha was incorrect') + case Answer.EXPIRED: + failure_id = add_failure('Captcha has expired') + case Answer.OK: + failure_id = None + user = generate_and_add_user(timestamp, token) + if failure_id is not None: + url = url_for('home', token=token, failure=failure_id) + raise abort(redirect(url, 303)) + case dict(): + user = user_or_token + url = url_for('home', token=user['token']) + return redirect(url, 303) + @current_app.route('/static/') @with_user_from(request) @clean_cache_headers diff --git a/anonstream/routes/wrappers.py b/anonstream/routes/wrappers.py index 9a93056..a18bd46 100644 --- a/anonstream/routes/wrappers.py +++ b/anonstream/routes/wrappers.py @@ -71,50 +71,69 @@ def auth_required(f): return wrapper -def with_user_from(context): +def generate_and_add_user(timestamp, token=None, broadcaster=False): + token = token or generate_token() + user = generate_user( + timestamp=timestamp, + token=token, + broadcaster=broadcaster, + presence=Presence.NOTWATCHING, + ) + USERS_BY_TOKEN[token] = user + USERS_UPDATE_BUFFER.add(token) + return user + +def with_user_from(context, fallback_to_token=False): def with_user_from_context(f): @wraps(f) async def wrapper(*args, **kwargs): timestamp = get_timestamp() - # Check if broadcaster + # Get token broadcaster = check_auth(context) + token_from_args = context.args.get('token') + token_from_cookie = try_unquote(context.cookies.get('token')) + token_from_context = token_from_args or token_from_cookie if broadcaster: token = CONFIG['AUTH_TOKEN'] + elif CONFIG['ACCESS_CAPTCHA']: + token = token_from_context else: - token = ( - context.args.get('token') - or try_unquote(context.cookies.get('token')) - or generate_token() - ) - if hmac.compare_digest(token, CONFIG['AUTH_TOKEN']): - raise abort(401) + token = token_from_context or generate_token() # Reject invalid tokens - if not RE_TOKEN.fullmatch(token): + if isinstance(token, str) and not RE_TOKEN.fullmatch(token): raise abort(400) - # Update / create user - user = USERS_BY_TOKEN.get(token) - if user is not None: - see(user) - else: - user = generate_user( - timestamp=timestamp, - token=token, - broadcaster=broadcaster, - presence=Presence.NOTWATCHING, - ) - USERS_BY_TOKEN[token] = user + # Only logged in broadcaster may have the broadcaster's token + if ( + not broadcaster + and isinstance(token, str) + and hmac.compare_digest(token, CONFIG['AUTH_TOKEN']) + ): + raise abort(401) - # Add to the users update buffer - USERS_UPDATE_BUFFER.add(token) + # Create response + user = USERS_BY_TOKEN.get(token) + if CONFIG['ACCESS_CAPTCHA'] and not broadcaster: + if user is not None: + user['last']['seen'] = timestamp + response = await f(timestamp, user, *args, **kwargs) + elif fallback_to_token: + #assert not broadcaster + response = await f(timestamp, token, *args, **kwargs) + else: + raise abort(403) + else: + if user is None: + user = generate_and_add_user(timestamp, token, broadcaster) + response = await f(timestamp, user, *args, **kwargs) # Set cookie - response = await f(timestamp, user, *args, **kwargs) - if try_unquote(context.cookies.get('token')) != token: + if token_from_cookie != token: response = await make_response(response) response.headers['Set-Cookie'] = f'token={quote(token)}; path=/' + return response return wrapper diff --git a/anonstream/templates/captcha.html b/anonstream/templates/captcha.html new file mode 100644 index 0000000..6a6058b --- /dev/null +++ b/anonstream/templates/captcha.html @@ -0,0 +1,60 @@ + + + + + + + + + +
+ + + + {% if failure is not none %}

{{ failure }}

{% endif %} +
+ + diff --git a/config.toml b/config.toml index ff4b3ea..95b2d22 100644 --- a/config.toml +++ b/config.toml @@ -24,6 +24,9 @@ stream_initial_buffer = 3 file = "title.txt" file_cache_lifetime = 0.5 +[access] +captcha = true + [captcha] lifetime = 1800 fonts = [] From 35ce606d649f3b165317cf74e6ad209afdaaef7d Mon Sep 17 00:00:00 2001 From: n9k Date: Fri, 17 Jun 2022 04:40:23 +0000 Subject: [PATCH 13/19] Custom error pages --- anonstream/routes/__init__.py | 1 + anonstream/routes/errors.py | 8 ++++++++ anonstream/templates/error.html | 27 +++++++++++++++++++++++++++ 3 files changed, 36 insertions(+) create mode 100644 anonstream/routes/errors.py create mode 100644 anonstream/templates/error.html diff --git a/anonstream/routes/__init__.py b/anonstream/routes/__init__.py index 2bc5259..b537c93 100644 --- a/anonstream/routes/__init__.py +++ b/anonstream/routes/__init__.py @@ -1,6 +1,7 @@ # SPDX-FileCopyrightText: 2022 n9k # SPDX-License-Identifier: AGPL-3.0-or-later +import anonstream.routes.errors import anonstream.routes.core import anonstream.routes.websocket import anonstream.routes.nojs diff --git a/anonstream/routes/errors.py b/anonstream/routes/errors.py new file mode 100644 index 0000000..d82a930 --- /dev/null +++ b/anonstream/routes/errors.py @@ -0,0 +1,8 @@ +from quart import current_app, render_template + +from werkzeug.exceptions import default_exceptions + +for error in default_exceptions: + async def handle(error): + return await render_template('error.html', error=error), error.code + current_app.register_error_handler(error, handle) diff --git a/anonstream/templates/error.html b/anonstream/templates/error.html new file mode 100644 index 0000000..42c71d3 --- /dev/null +++ b/anonstream/templates/error.html @@ -0,0 +1,27 @@ + + + + + {{ error.code }} {{ error.name }} + + + +

{{ error.code }} {{ error.name }}

+ + From 0548065b1d1aa1ffc182e2ff591635384d6fafcf Mon Sep 17 00:00:00 2001 From: n9k Date: Wed, 22 Jun 2022 07:52:07 +0000 Subject: [PATCH 14/19] Error pages: custom descriptions --- anonstream/routes/core.py | 60 ++++++++++++++++++++------------- anonstream/routes/wrappers.py | 44 ++++++++++++++++-------- anonstream/templates/error.html | 17 +++++++++- anonstream/user.py | 2 +- 4 files changed, 83 insertions(+), 40 deletions(-) diff --git a/anonstream/routes/core.py b/anonstream/routes/core.py index 73637c5..69f0b15 100644 --- a/anonstream/routes/core.py +++ b/anonstream/routes/core.py @@ -3,14 +3,14 @@ import math -from quart import current_app, request, render_template, abort, make_response, redirect, url_for, abort, send_from_directory -from werkzeug.exceptions import TooManyRequests +from quart import current_app, request, render_template, abort, make_response, redirect, url_for, send_from_directory +from werkzeug.exceptions import NotFound, TooManyRequests from anonstream.access import add_failure, pop_failure from anonstream.captcha import get_captcha_image, get_random_captcha_digest from anonstream.segments import segments, StopSendingSegments from anonstream.stream import is_online, get_stream_uptime -from anonstream.user import watching, create_eyes, renew_eyes, EyesException, RatelimitedEyes +from anonstream.user import watching, create_eyes, renew_eyes, EyesException, RatelimitedEyes, TooManyEyes from anonstream.routes.wrappers import with_user_from, auth_required, clean_cache_headers, generate_and_add_user from anonstream.helpers.captcha import check_captcha_digest, Answer from anonstream.utils.security import generate_csp @@ -43,28 +43,40 @@ async def home(timestamp, user_or_token): @with_user_from(request) async def stream(timestamp, user): if not is_online(): - return abort(404) - - try: - eyes_id = create_eyes(user, dict(request.headers)) - except RatelimitedEyes as e: - retry_after, *_ = e.args - return TooManyRequests(), {'Retry-After': math.ceil(retry_after)} - except EyesException: - return abort(429) - - def segment_read_hook(uri): + raise NotFound('The stream is offline.') + else: try: - renew_eyes(user, eyes_id, just_read_new_segment=True) - except EyesException as e: - raise StopSendingSegments(f'eyes {eyes_id} not allowed: {e!r}') from e - print(f'{uri}: {eyes_id}~{user["token"]}') - watching(user) - - generator = segments(segment_read_hook, token=user['token']) - response = await make_response(generator) - response.headers['Content-Type'] = 'video/mp4' - response.timeout = None + eyes_id = create_eyes(user, dict(request.headers)) + except RatelimitedEyes as e: + retry_after, *_ = e.args + error = TooManyRequests( + f'You have requested the stream recently. ' + f'Try again in {retry_after:.1f} seconds.' + ) + response = await current_app.handle_http_exception(error) + response = await make_response(response) + response.headers['Retry-After'] = math.ceil(retry_after) + raise abort(response) + except TooManyEyes as e: + n_eyes, *_ = e.args + raise TooManyRequests( + f'You have made {n_eyes} concurrent requests for the stream. ' + f'End one of those before making a new request.' + ) + else: + def segment_read_hook(uri): + try: + renew_eyes(user, eyes_id, just_read_new_segment=True) + except EyesException as e: + raise StopSendingSegments( + f'eyes {eyes_id} not allowed: {e!r}' + ) from e + print(f'{uri}: {eyes_id}~{user["token"]}') + watching(user) + generator = segments(segment_read_hook, token=user['token']) + response = await make_response(generator) + response.headers['Content-Type'] = 'video/mp4' + response.timeout = None return response @current_app.route('/login') diff --git a/anonstream/routes/wrappers.py b/anonstream/routes/wrappers.py index a18bd46..339489b 100644 --- a/anonstream/routes/wrappers.py +++ b/anonstream/routes/wrappers.py @@ -8,7 +8,8 @@ import string from functools import wraps from urllib.parse import quote, unquote -from quart import current_app, request, abort, make_response, render_template, request +from quart import current_app, request, make_response, render_template, request, url_for, Markup +from werkzeug.exceptions import BadRequest, Unauthorized, Forbidden from werkzeug.security import check_password_hash from anonstream.broadcast import broadcast @@ -57,18 +58,18 @@ def auth_required(f): 'their terminal.' ) if request.authorization is None: - body = ( - f'\n' - f'

{hint}

\n' - ) + description = hint else: - body = ( - f'\n' - f'

Wrong username or password. Refresh the page to try again.

\n' - f'

{hint}

\n' + description = Markup( + f'Wrong username or password. Refresh the page to try again. ' + f'
' + f'{hint}' ) - return body, 401, {'WWW-Authenticate': 'Basic'} - + error = Unauthorized(description) + response = await current_app.handle_http_exception(error) + response = await make_response(response) + response.headers['WWW-Authenticate'] = 'Basic' + return response return wrapper def generate_and_add_user(timestamp, token=None, broadcaster=False): @@ -103,7 +104,11 @@ def with_user_from(context, fallback_to_token=False): # Reject invalid tokens if isinstance(token, str) and not RE_TOKEN.fullmatch(token): - raise abort(400) + raise BadRequest(Markup( + f'Your token contains disallowed characters or is too ' + f'long. Tokens must match this regular expression:
' + f'{RE_TOKEN.pattern}' + )) # Only logged in broadcaster may have the broadcaster's token if ( @@ -111,7 +116,13 @@ def with_user_from(context, fallback_to_token=False): and isinstance(token, str) and hmac.compare_digest(token, CONFIG['AUTH_TOKEN']) ): - raise abort(401) + raise Unauthorized(Markup( + f"You are using the broadcaster's token but you are " + f"not logged in. The broadcaster should " + f"click here " + f"and log in with the credentials printed in their " + f"terminal when they started anonstream." + )) # Create response user = USERS_BY_TOKEN.get(token) @@ -123,7 +134,12 @@ def with_user_from(context, fallback_to_token=False): #assert not broadcaster response = await f(timestamp, token, *args, **kwargs) else: - raise abort(403) + raise Forbidden(Markup( + f"You have not solved the access captcha. " + f"" + f"Click here." + f"" + )) else: if user is None: user = generate_and_add_user(timestamp, token, broadcaster) diff --git a/anonstream/templates/error.html b/anonstream/templates/error.html index 42c71d3..38aa7ce 100644 --- a/anonstream/templates/error.html +++ b/anonstream/templates/error.html @@ -13,15 +13,30 @@ text-align: center; text-shadow: 2px 0px 1px orangered; } + main { + margin: auto; + max-width: 52rem; + } h1 { font-size: 32pt; } a { color: #42a5d7; } + code { + background-color: #333; + padding: 2px; + border-radius: 2px; + overflow-wrap: anywhere; + } -

{{ error.code }} {{ error.name }}

+
+

{{ error.code }} {{ error.name }}

+ {% if error.description != error.__class__.description %} +

{{ error.description }}

+ {% endif %} +
diff --git a/anonstream/user.py b/anonstream/user.py index d9f4eea..ccdb360 100644 --- a/anonstream/user.py +++ b/anonstream/user.py @@ -259,7 +259,7 @@ def create_eyes(timestamp, user, headers): # Treat eyes as a stack, do not create new eyes if it would # cause the limit to be exceeded if not CONFIG['FLOOD_VIDEO_OVERWRITE']: - raise TooManyEyes + raise TooManyEyes(len(user['eyes']['current'])) # Treat eyes as a queue, expire old eyes upon creating new eyes # if the limit would have been exceeded otherwise elif user['eyes']['current']: From 4a76fb023e2b4d2256c68d95d5086ce7b5f41c98 Mon Sep 17 00:00:00 2001 From: n9k Date: Wed, 22 Jun 2022 08:10:42 +0000 Subject: [PATCH 15/19] Access captcha: special case for websocket There doesn't seem to be a way to catch a 403 Forbidden error opening a websocket with JavaScript, so this commit changes the behaviour to this: open the websocket normally, send one "kick" message, close the websocket. --- anonstream/routes/websocket.py | 29 +++++++++++++++++------------ anonstream/static/anonstream.js | 5 +++++ 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/anonstream/routes/websocket.py b/anonstream/routes/websocket.py index 96dbb84..95048c7 100644 --- a/anonstream/routes/websocket.py +++ b/anonstream/routes/websocket.py @@ -9,16 +9,21 @@ 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(timestamp, user): - queue = asyncio.Queue() - user['websockets'][queue] = timestamp - reading(user, timestamp=timestamp) +@with_user_from(websocket, fallback_to_token=True) +async def live(timestamp, user_or_token): + match user_or_token: + case str() | None: + await websocket.send_json({'type': 'kick'}) + await websocket.close(1001) + case dict() as user: + queue = asyncio.Queue() + user['websockets'][queue] = timestamp + reading(user, timestamp=timestamp) - producer = websocket_outbound(queue, user) - consumer = websocket_inbound(queue, user) - try: - await asyncio.gather(producer, consumer) - finally: - see(user) - user['websockets'].pop(queue) + producer = websocket_outbound(queue, user) + consumer = websocket_inbound(queue, user) + try: + await asyncio.gather(producer, consumer) + finally: + see(user) + user['websockets'].pop(queue) diff --git a/anonstream/static/anonstream.js b/anonstream/static/anonstream.js index 3873e6f..4b071ff 100644 --- a/anonstream/static/anonstream.js +++ b/anonstream/static/anonstream.js @@ -831,6 +831,11 @@ const on_websocket_message = (event) => { ws.send(JSON.stringify(payload)); break; + case "kick": + console.log("ws kick"); + window.location.reload(); + break; + default: console.log("incomprehensible websocket message", receipt); } From 6046598ed8c7881464c92a606f6188362c0d5d22 Mon Sep 17 00:00:00 2001 From: n9k Date: Wed, 22 Jun 2022 08:31:06 +0000 Subject: [PATCH 16/19] Fix deletion of old messages exceeding threshold --- anonstream/chat.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/anonstream/chat.py b/anonstream/chat.py index 91e282c..e791221 100644 --- a/anonstream/chat.py +++ b/anonstream/chat.py @@ -108,7 +108,7 @@ def add_chat_message(user, nonce, comment, ignore_empty=False): MESSAGES_BY_ID[message_id] = message while len(MESSAGES_BY_ID) > CONFIG['MAX_CHAT_MESSAGES']: - MESSAGES_BY_ID.pop(last=False) + MESSAGES_BY_ID.popitem(last=False) # Notify event sockets that a chat message was added notify_event_sockets({ From 95a940a14f26a7bacfa484a4e19b256afb4bd4a8 Mon Sep 17 00:00:00 2001 From: n9k Date: Wed, 22 Jun 2022 08:35:35 +0000 Subject: [PATCH 17/19] Limit number of stored failures Failures are messages shown on the access captcha screen when the captcha answer was not accepted for whatever reason. --- anonstream/__init__.py | 2 +- anonstream/access.py | 5 +++++ anonstream/config.py | 2 ++ config.toml | 1 + 4 files changed, 9 insertions(+), 1 deletion(-) diff --git a/anonstream/__init__.py b/anonstream/__init__.py index 1e4a915..f918218 100644 --- a/anonstream/__init__.py +++ b/anonstream/__init__.py @@ -38,7 +38,7 @@ def create_app(toml_config): app.captcha_factory = create_captcha_factory(app.config['CAPTCHA_FONTS']) app.captcha_signer = create_captcha_signer(app.config['SECRET_KEY']) - app.failures = {} + app.failures = OrderedDict() # State for tasks app.users_update_buffer = set() diff --git a/anonstream/access.py b/anonstream/access.py index 822acd2..3f76c02 100644 --- a/anonstream/access.py +++ b/anonstream/access.py @@ -2,6 +2,7 @@ import time from quart import current_app +CONFIG = current_app.config FAILURES = current_app.failures def add_failure(message): @@ -9,6 +10,10 @@ def add_failure(message): while timestamp in FAILURES: timestamp += 1 FAILURES[timestamp] = message + + while len(FAILURES) > CONFIG['MAX_FAILURES']: + FAILURES.popitem(last=False) + return timestamp def pop_failure(failure_id): diff --git a/anonstream/config.py b/anonstream/config.py index 0c5c580..346f2c6 100644 --- a/anonstream/config.py +++ b/anonstream/config.py @@ -84,11 +84,13 @@ def toml_to_flask_section_names(config): def toml_to_flask_section_memory(config): cfg = config['memory'] assert cfg['states'] >= 0 + assert cfg['failures'] >= 0 assert cfg['chat_scrollback'] >= 0 assert cfg['chat_messages'] >= cfg['chat_scrollback'] return { 'MAX_STATES': cfg['states'], 'MAX_CAPTCHAS': cfg['captchas'], + 'MAX_FAILURES': cfg['failures'], 'MAX_CHAT_MESSAGES': cfg['chat_messages'], 'MAX_CHAT_SCROLLBACK': cfg['chat_scrollback'], } diff --git a/config.toml b/config.toml index 95b2d22..b3be6e1 100644 --- a/config.toml +++ b/config.toml @@ -38,6 +38,7 @@ foreground_color = "#dddddd" [memory] states = 32 captchas = 256 +failures = 256 chat_messages = 8192 chat_scrollback = 256 From c3237890adabb16c97a44d0e5772516d98ca6dfa Mon Sep 17 00:00:00 2001 From: n9k Date: Wed, 22 Jun 2022 08:39:16 +0000 Subject: [PATCH 18/19] Rule out edge case where MAX_CAPTCHAS is 0 --- anonstream/captcha.py | 2 -- anonstream/config.py | 1 + 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/anonstream/captcha.py b/anonstream/captcha.py index fb1b0af..49ffd28 100644 --- a/anonstream/captcha.py +++ b/anonstream/captcha.py @@ -33,8 +33,6 @@ def get_random_captcha_digest(): solution = generate_random_captcha_solution() digest = generate_captcha_digest(CAPTCHA_SIGNER, salt, solution) CAPTCHAS[digest] = {'solution': solution} - while len(CAPTCHAS) >= CONFIG['MAX_CAPTCHAS']: - CAPTCHAS.popitem(last=False) return digest diff --git a/anonstream/config.py b/anonstream/config.py index 346f2c6..755d84e 100644 --- a/anonstream/config.py +++ b/anonstream/config.py @@ -84,6 +84,7 @@ def toml_to_flask_section_names(config): def toml_to_flask_section_memory(config): cfg = config['memory'] assert cfg['states'] >= 0 + assert cfg['captchas'] >= 1 assert cfg['failures'] >= 0 assert cfg['chat_scrollback'] >= 0 assert cfg['chat_messages'] >= cfg['chat_scrollback'] From 76af3afd057a5dc4949ac60054a86f9592d2a44c Mon Sep 17 00:00:00 2001 From: n9k Date: Wed, 22 Jun 2022 08:56:01 +0000 Subject: [PATCH 19/19] README.me: mention `access.captcha` config option --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 685a8cb..6f07ea0 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,10 @@ to know what they do: locations of fonts for the captcha, leaving it blank will use the default font +* `access/captcha`: + if true, users must complete a captcha before accessing the site + proper + Run it: ```sh python -m anonstream