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 diff --git a/anonstream/__init__.py b/anonstream/__init__.py index 58b318d..f918218 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 @@ -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 @@ -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 = OrderedDict() + # 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/__main__.py b/anonstream/__main__.py index 212bdce..92c2a46 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)})' @@ -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) diff --git a/anonstream/access.py b/anonstream/access.py new file mode 100644 index 0000000..3f76c02 --- /dev/null +++ b/anonstream/access.py @@ -0,0 +1,23 @@ +import time + +from quart import current_app + +CONFIG = current_app.config +FAILURES = current_app.failures + +def add_failure(message): + timestamp = time.time_ns() // 1_000_000 + 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): + try: + return FAILURES.pop(failure_id) + except KeyError: + return None 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/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({ diff --git a/anonstream/config.py b/anonstream/config.py index 376505d..755d84e 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) @@ -83,11 +84,14 @@ 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'] 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/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/__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/core.py b/anonstream/routes/core.py index 92425be..69f0b15 100644 --- a/anonstream/routes/core.py +++ b/anonstream/routes/core.py @@ -3,64 +3,126 @@ import math -from quart import current_app, request, render_template, abort, make_response, redirect, url_for, abort -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.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 watched, create_eyes, renew_eyes, EyesException, RatelimitedEyes -from anonstream.routes.wrappers import with_user_from, auth_required +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 +CAPTCHA_SIGNER = current_app.captcha_signer +STATIC_DIRECTORY = current_app.root_path / 'static' + @current_app.route('/') -@with_user_from(request) -async def home(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) -async def stream(user): +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"]}') - watched(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') @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) -async def captcha(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: return abort(410) 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 +async def static(timestamp, user, filename): + return await send_from_directory(STATIC_DIRECTORY, filename) 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/routes/nojs.py b/anonstream/routes/nojs.py index 0a6c836..0ce8d12 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 @@ -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,8 @@ 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', {'csp': generate_csp()}, @@ -59,12 +60,13 @@ async def nojs_chat_messages(user): @current_app.route('/chat/messages') @with_user_from(request) -async def nojs_chat_messages_redirect(user): - return redirect(url_for('nojs_chat_messages', token=user['token'], _anchor='end')) +async def nojs_chat_messages_redirect(timestamp, user): + 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) -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', @@ -79,7 +81,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' @@ -99,7 +101,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( @@ -108,11 +110,12 @@ async def nojs_chat_form_redirect(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) -async def nojs_submit_message(user): +async def nojs_submit_message(timestamp, user): form = await request.form comment = form.get('comment', '') @@ -150,16 +153,17 @@ async def nojs_submit_message(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) -async def nojs_submit_appearance(user): +async def nojs_submit_appearance(timestamp, user): form = await request.form # Collect form data @@ -187,9 +191,10 @@ async def nojs_submit_appearance(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) diff --git a/anonstream/routes/websocket.py b/anonstream/routes/websocket.py index 12eafd4..95048c7 100644 --- a/anonstream/routes/websocket.py +++ b/anonstream/routes/websocket.py @@ -2,25 +2,28 @@ # 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 +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) - user['websockets'][queue] = -inf +@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/routes/wrappers.py b/anonstream/routes/wrappers.py index c5ac817..339489b 100644 --- a/anonstream/routes/wrappers.py +++ b/anonstream/routes/wrappers.py @@ -5,16 +5,18 @@ import hashlib import hmac import re import string -import time 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 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 @@ -31,13 +33,19 @@ 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 ( 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): @@ -50,64 +58,98 @@ 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 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 = int(time.time()) + 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 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): - raise abort(400) + if isinstance(token, str) and not RE_TOKEN.fullmatch(token): + raise BadRequest(Markup( + f'Your token contains disallowed characters or is too ' + f'long. Tokens must match this regular expression:
' + f'{RE_TOKEN.pattern}' + )) - # Update / create 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 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) - if user is not None: - see(user) + 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 Forbidden(Markup( + f"You have not solved the access captcha. " + f"" + f"Click here." + f"" + )) else: - user = generate_user( - timestamp=timestamp, - token=token, - broadcaster=broadcaster, - presence=Presence.NOTWATCHING, - ) - USERS_BY_TOKEN[token] = user - - # Add to the users update buffer - USERS_UPDATE_BUFFER.add(token) + if user is None: + user = generate_and_add_user(timestamp, token, broadcaster) + response = await f(timestamp, user, *args, **kwargs) # Set cookie - response = await f(user, *args, **kwargs) - if context.cookies.get('token') != token: + if token_from_cookie != 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 @@ -127,3 +169,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 diff --git a/anonstream/static/anonstream.js b/anonstream/static/anonstream.js index 1c68be4..4b071ff 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 **/ @@ -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); } 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 **/ 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/anonstream/templates/error.html b/anonstream/templates/error.html new file mode 100644 index 0000000..38aa7ce --- /dev/null +++ b/anonstream/templates/error.html @@ -0,0 +1,42 @@ + + + + + {{ 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 914f48f..ccdb360 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 { @@ -167,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) @@ -255,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']: 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): diff --git a/config.toml b/config.toml index ff4b3ea..b3be6e1 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 = [] @@ -35,6 +38,7 @@ foreground_color = "#dddddd" [memory] states = 32 captchas = 256 +failures = 256 chat_messages = 8192 chat_scrollback = 256