diff --git a/anonstream/config.py b/anonstream/config.py index 204e306..dbd91a5 100644 --- a/anonstream/config.py +++ b/anonstream/config.py @@ -82,6 +82,7 @@ def toml_to_flask_section_memory(config): def toml_to_flask_section_tasks(config): cfg = config['tasks'] return { + 'TASK_ROTATE_EYES': cfg['rotate_eyes'], 'TASK_ROTATE_USERS': cfg['rotate_users'], 'TASK_ROTATE_CAPTCHAS': cfg['rotate_captchas'], 'TASK_ROTATE_WEBSOCKETS': cfg['rotate_websockets'], @@ -112,11 +113,16 @@ def toml_to_flask_section_chat(config): def toml_to_flask_section_flood(config): cfg = config['flood'] + assert cfg['video']['max_eyes'] >= 0 return { 'FLOOD_MESSAGE_DURATION': cfg['messages']['duration'], 'FLOOD_MESSAGE_THRESHOLD': cfg['messages']['threshold'], 'FLOOD_LINE_DURATION': cfg['lines']['duration'], 'FLOOD_LINE_THRESHOLD': cfg['lines']['threshold'], + 'FLOOD_VIDEO_MAX_EYES': cfg['video']['max_eyes'], + #'FLOOD_VIDEO_COOLDOWN': cfg['video']['cooldown'], + 'FLOOD_VIDEO_EYES_EXPIRE_AFTER': cfg['video']['expire_after'], + 'FLOOD_VIDEO_OVERWRITE': cfg['video']['overwrite'], } def toml_to_flask_section_captcha(config): diff --git a/anonstream/quart.py b/anonstream/quart.py index 5761e86..dfd5e16 100644 --- a/anonstream/quart.py +++ b/anonstream/quart.py @@ -6,7 +6,7 @@ from quart.asgi import ASGIHTTPConnection as ASGIHTTPConnection_ from quart.utils import encode_headers -RESPONSE_ITERATOR_TIMEOUT = 10 +RESPONSE_ITERATOR_TIMEOUT = 10.0 class ASGIHTTPConnection(ASGIHTTPConnection_): diff --git a/anonstream/routes/core.py b/anonstream/routes/core.py index 42149cc..c4642a8 100644 --- a/anonstream/routes/core.py +++ b/anonstream/routes/core.py @@ -6,7 +6,7 @@ from quart import current_app, request, render_template, abort, make_response, r 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, ExpiredEyes +from anonstream.user import watched, create_eyes, renew_eyes, EyesException from anonstream.routes.wrappers import with_user_from, auth_required from anonstream.utils.security import generate_csp @@ -25,12 +25,16 @@ async def stream(user): if not is_online(): return abort(404) - eyes_id = create_eyes(user, dict(request.headers)) + try: + eyes_id = create_eyes(user, dict(request.headers)) + except EyesException: + return abort(429) + def segment_read_hook(uri): try: - renew_eyes(user, eyes_id) - except ExpiredEyes as e: - raise StopSendingSegments(f'eyes {eyes_id} expired: {e}') from e + 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) diff --git a/anonstream/tasks.py b/anonstream/tasks.py index 0047620..a31368a 100644 --- a/anonstream/tasks.py +++ b/anonstream/tasks.py @@ -43,6 +43,21 @@ def with_period(period): return periodically +@with_period(CONFIG['TASK_ROTATE_EYES']) +@with_timestamp +async def t_delete_eyes(timestamp, iteration): + if iteration == 0: + return + else: + for user in USERS: + to_delete = [] + for eyes_id, eyes in user['eyes']['current'].items(): + renewed_ago = timestamp - eyes['renewed'] + if renewed_ago >= CONFIG['FLOOD_VIDEO_EYES_EXPIRE_AFTER']: + to_delete.append(eyes_id) + for eyes_id in to_delete: + user['eyes']['current'].pop(eyes_id) + @with_period(CONFIG['TASK_ROTATE_USERS']) @with_timestamp async def t_sunset_users(timestamp, iteration): @@ -166,6 +181,7 @@ async def t_broadcast_stream_info_update(iteration): if payload: broadcast(USERS, payload={'type': 'info', **payload}) +current_app.add_background_task(t_delete_eyes) current_app.add_background_task(t_sunset_users) current_app.add_background_task(t_expire_captchas) current_app.add_background_task(t_close_websockets) diff --git a/anonstream/user.py b/anonstream/user.py index 6a6cc6a..5d04d85 100644 --- a/anonstream/user.py +++ b/anonstream/user.py @@ -25,7 +25,16 @@ class BadAppearance(ValueError): class BadCaptcha(ValueError): pass -class ExpiredEyes(Exception): +class EyesException(Exception): + pass + +class TooManyEyes(EyesException): + pass + +class DeletedEyes(EyesException): + pass + +class ExpiredEyes(EyesException): pass def add_state(user, **state): @@ -225,6 +234,16 @@ def get_users_by_presence(timestamp): @with_timestamp def create_eyes(timestamp, user, headers): + if len(user['eyes']['current']) >= CONFIG['FLOOD_VIDEO_MAX_EYES']: + # 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 + # treat eyes as a queue, expire old eyes upon creating new eyes + # if the limit would have been exceeded + elif user['eyes']['current']: + oldest_eyes_id = min(user['eyes']['current']) + user['eyes']['current'].pop(oldest_eyes_id) eyes_id = user['eyes']['total'] user['eyes']['total'] += 1 user['eyes']['current'][eyes_id] = { @@ -238,12 +257,15 @@ def create_eyes(timestamp, user, headers): return eyes_id @with_timestamp -def renew_eyes(timestamp, user, eyes_id): +def renew_eyes(timestamp, user, eyes_id, just_read_new_segment=False): try: eyes = user['eyes']['current'][eyes_id] except KeyError: - raise ExpiredEyes(None) - if timestamp - eyes['renewed'] >= 20.0: # TODO remove magic number + raise DeletedEyes(eyes_id) + renewed_ago = timestamp - eyes['renewed'] + if renewed_ago >= CONFIG['FLOOD_VIDEO_EYES_EXPIRE_AFTER']: user['eyes']['current'].pop(eyes_id) raise ExpiredEyes(eyes) eyes['renewed'] = timestamp + if just_read_new_segment: + eyes['n_segments'] += 1 diff --git a/config.toml b/config.toml index 717c64e..5fe9df0 100644 --- a/config.toml +++ b/config.toml @@ -34,6 +34,7 @@ chat_messages = 8192 chat_scrollback = 256 [tasks] +rotate_eyes = 3.0 rotate_users = 60.0 rotate_captchas = 60.0 rotate_websockets = 2.0 @@ -60,6 +61,12 @@ threshold = 4 duration = 20.0 threshold = 20 +[flood.video] +max_eyes = 3 +#cooldown = 12.0 +expire_after = 5.0 +overwrite = true + [thresholds] user_notwatching = 8.0 user_tentative = 20.0