diff --git a/anonstream/__init__.py b/anonstream/__init__.py index 563ad18..8579eac 100644 --- a/anonstream/__init__.py +++ b/anonstream/__init__.py @@ -1,94 +1,37 @@ # SPDX-FileCopyrightText: 2022 n9k [https://git.076.ne.jp/ninya9k] # SPDX-License-Identifier: AGPL-3.0-or-later -import os -import secrets -import toml from collections import OrderedDict +import toml from quart_compress import Compress -from werkzeug.security import generate_password_hash -from anonstream.quart import Quart +from anonstream.config import update_flask_from_toml from anonstream.utils.captcha import create_captcha_factory, create_captcha_signer -from anonstream.utils.colour import color_to_colour -from anonstream.utils.user import generate_token +from anonstream.quart import Quart compress = Compress() def create_app(config_file): - with open(config_file) as fp: - config = toml.load(fp) + app = Quart('anonstream') + app.jinja_options['trim_blocks'] = True + app.jinja_options['lstrip_blocks'] = True - auth_password = secrets.token_urlsafe(6) - auth_pwhash = generate_password_hash(auth_password) - print('Broadcaster username:', config['auth']['username']) + with open(config_file) as fp: + toml_config = toml.load(fp) + auth_password = update_flask_from_toml(toml_config, app.config) + + print('Broadcaster username:', app.config['AUTH_USERNAME']) print('Broadcaster password:', auth_password) - app = Quart('anonstream') - app.jinja_options.update({ - 'trim_blocks': True, - 'lstrip_blocks': True, - }) + # Compress some responses + compress.init_app(app) app.config.update({ - 'SECRET_KEY_STRING': config['secret_key'], - 'SECRET_KEY': config['secret_key'].encode(), - 'AUTH_USERNAME': config['auth']['username'], - 'AUTH_PWHASH': auth_pwhash, - 'AUTH_TOKEN': generate_token(), - 'SEGMENT_DIRECTORY': os.path.realpath(config['segments']['directory']), - 'SEGMENT_PLAYLIST': os.path.join(os.path.realpath(config['segments']['directory']), config['segments']['playlist']), - 'SEGMENT_PLAYLIST_CACHE_LIFETIME': config['segments']['playlist_cache_lifetime'], - 'SEGMENT_PLAYLIST_STALE_THRESHOLD': config['segments']['playlist_stale_threshold'], - 'SEGMENT_SEARCH_COOLDOWN': config['segments']['search_cooldown'], - 'SEGMENT_SEARCH_TIMEOUT': config['segments']['search_timeout'], - 'SEGMENT_STREAM_INITIAL_BUFFER': config['segments']['stream_initial_buffer'], - 'STREAM_TITLE': config['title']['file'], - 'STREAM_TITLE_CACHE_LIFETIME': config['title']['file_cache_lifetime'], - 'DEFAULT_HOST_NAME': config['names']['broadcaster'], - 'DEFAULT_ANON_NAME': config['names']['anonymous'], - 'MAX_STATES': config['memory']['states'], - 'MAX_CAPTCHAS': config['memory']['captchas'], - 'MAX_CHAT_MESSAGES': config['memory']['chat_messages'], - 'MAX_CHAT_SCROLLBACK': config['memory']['chat_scrollback'], - 'TASK_PERIOD_ROTATE_USERS': config['tasks']['rotate_users'], - 'TASK_PERIOD_ROTATE_CAPTCHAS': config['tasks']['rotate_captchas'], - 'TASK_PERIOD_ROTATE_WEBSOCKETS': config['tasks']['rotate_websockets'], - 'TASK_PERIOD_BROADCAST_PING': config['tasks']['broadcast_ping'], - 'TASK_PERIOD_BROADCAST_USERS_UPDATE': config['tasks']['broadcast_users_update'], - 'TASK_PERIOD_BROADCAST_STREAM_INFO_UPDATE': config['tasks']['broadcast_stream_info_update'], - 'THRESHOLD_USER_NOTWATCHING': config['thresholds']['user_notwatching'], - 'THRESHOLD_USER_TENTATIVE': config['thresholds']['user_tentative'], - 'THRESHOLD_USER_ABSENT': config['thresholds']['user_absent'], - 'THRESHOLD_NOJS_CHAT_TIMEOUT': config['thresholds']['nojs_chat_timeout'], - 'CHAT_COMMENT_MAX_LENGTH': config['chat']['max_name_length'], - 'CHAT_NAME_MAX_LENGTH': config['chat']['max_name_length'], - 'CHAT_NAME_MIN_CONTRAST': config['chat']['min_name_contrast'], - 'CHAT_BACKGROUND_COLOUR': color_to_colour(config['chat']['background_color']), - 'CHAT_LEGACY_TRIPCODE_ALGORITHM': config['chat']['legacy_tripcode_algorithm'], - 'FLOOD_MESSAGE_DURATION': config['flood']['messages']['duration'], - 'FLOOD_MESSAGE_THRESHOLD': config['flood']['messages']['threshold'], - 'FLOOD_LINE_DURATION': config['flood']['lines']['duration'], - 'FLOOD_LINE_THRESHOLD': config['flood']['lines']['threshold'], - 'CAPTCHA_LIFETIME': config['captcha']['lifetime'], - 'CAPTCHA_FONTS': config['captcha']['fonts'], - 'CAPTCHA_ALPHABET': config['captcha']['alphabet'], - 'CAPTCHA_LENGTH': config['captcha']['length'], - 'CAPTCHA_BACKGROUND_COLOUR': color_to_colour(config['captcha']['background_color']), - 'CAPTCHA_FOREGROUND_COLOUR': color_to_colour(config['captcha']['foreground_color']), + "COMPRESS_MIN_SIZE": 2048, + "COMPRESS_LEVEL": 9, }) - assert app.config['MAX_STATES'] >= 0 - assert app.config['MAX_CHAT_SCROLLBACK'] >= 0 - assert ( - app.config['MAX_CHAT_MESSAGES'] >= app.config['MAX_CHAT_SCROLLBACK'] - ) - assert ( - app.config['THRESHOLD_USER_ABSENT'] - >= app.config['THRESHOLD_USER_TENTATIVE'] - >= app.config['THRESHOLD_USER_NOTWATCHING'] - ) - + # Global state: messages, users, captchas app.messages_by_id = OrderedDict() app.messages = app.messages_by_id.values() @@ -110,7 +53,7 @@ def create_app(config_file): @app.after_serving async def shutdown(): - # make all background tasks finish + # Force all background tasks to finish for task in app.background_sleep: task.cancel() @@ -119,11 +62,4 @@ def create_app(config_file): import anonstream.routes import anonstream.tasks - # Compress some responses - compress.init_app(app) - app.config.update({ - "COMPRESS_MIN_SIZE": 2048, - "COMPRESS_LEVEL": 9, - }) - return app diff --git a/anonstream/config.py b/anonstream/config.py new file mode 100644 index 0000000..0a489c2 --- /dev/null +++ b/anonstream/config.py @@ -0,0 +1,130 @@ +import os +import secrets + +from werkzeug.security import generate_password_hash + +from anonstream.utils.colour import color_to_colour +from anonstream.utils.user import generate_token + +def update_flask_from_toml(toml_config, flask_config): + auth_password = secrets.token_urlsafe(6) + auth_pwhash = generate_password_hash(auth_password) + + flask_config.update({ + 'SECRET_KEY_STRING': toml_config['secret_key'], + 'SECRET_KEY': toml_config['secret_key'].encode(), + 'AUTH_USERNAME': toml_config['auth']['username'], + 'AUTH_PWHASH': auth_pwhash, + 'AUTH_TOKEN': generate_token(), + }) + for flask_section in toml_to_flask_sections(toml_config): + flask_config.update(flask_section) + + return auth_password + +def toml_to_flask_sections(config): + TOML_TO_FLASK_SECTIONS = ( + toml_to_flask_section_segments, + toml_to_flask_section_title, + toml_to_flask_section_names, + toml_to_flask_section_memory, + toml_to_flask_section_tasks, + toml_to_flask_section_thresholds, + toml_to_flask_section_chat, + toml_to_flask_section_flood, + toml_to_flask_section_captcha, + ) + for toml_to_flask_section in TOML_TO_FLASK_SECTIONS: + yield toml_to_flask_section(config) + +def toml_to_flask_section_segments(config): + cfg = config['segments'] + return { + 'SEGMENT_DIRECTORY': os.path.realpath(cfg['directory']), + 'SEGMENT_PLAYLIST': os.path.join( + os.path.realpath(cfg['directory']), + cfg['playlist'], + ), + 'SEGMENT_PLAYLIST_CACHE_LIFETIME': cfg['playlist_cache_lifetime'], + 'SEGMENT_PLAYLIST_STALE_THRESHOLD': cfg['playlist_stale_threshold'], + 'SEGMENT_SEARCH_COOLDOWN': cfg['search_cooldown'], + 'SEGMENT_SEARCH_TIMEOUT': cfg['search_timeout'], + 'SEGMENT_STREAM_INITIAL_BUFFER': cfg['stream_initial_buffer'], + } + +def toml_to_flask_section_title(config): + cfg = config['title'] + return { + 'STREAM_TITLE': cfg['file'], + 'STREAM_TITLE_CACHE_LIFETIME': cfg['file_cache_lifetime'], + } + +def toml_to_flask_section_names(config): + cfg = config['names'] + return { + 'DEFAULT_HOST_NAME': cfg['broadcaster'], + 'DEFAULT_ANON_NAME': cfg['anonymous'], + } + +def toml_to_flask_section_memory(config): + cfg = config['memory'] + assert cfg['states'] >= 0 + assert cfg['chat_scrollback'] >= 0 + assert cfg['chat_messages'] >= cfg['chat_scrollback'] + return { + 'MAX_STATES': cfg['states'], + 'MAX_CAPTCHAS': cfg['captchas'], + 'MAX_CHAT_MESSAGES': cfg['chat_messages'], + 'MAX_CHAT_SCROLLBACK': cfg['chat_scrollback'], + } + +def toml_to_flask_section_tasks(config): + cfg = config['tasks'] + return { + 'TASK_ROTATE_USERS': cfg['rotate_users'], + 'TASK_ROTATE_CAPTCHAS': cfg['rotate_captchas'], + 'TASK_ROTATE_WEBSOCKETS': cfg['rotate_websockets'], + 'TASK_BROADCAST_PING': cfg['broadcast_ping'], + 'TASK_BROADCAST_USERS_UPDATE': cfg['broadcast_users_update'], + 'TASK_BROADCAST_STREAM_INFO_UPDATE': cfg['broadcast_stream_info_update'], + } + +def toml_to_flask_section_thresholds(config): + cfg = config['thresholds'] + assert cfg['user_notwatching'] <= cfg['user_tentative'] <= cfg['user_absent'] + return { + 'THRESHOLD_USER_NOTWATCHING': cfg['user_notwatching'], + 'THRESHOLD_USER_TENTATIVE': cfg['user_tentative'], + 'THRESHOLD_USER_ABSENT': cfg['user_absent'], + 'THRESHOLD_NOJS_CHAT_TIMEOUT': cfg['nojs_chat_timeout'], + } + +def toml_to_flask_section_chat(config): + cfg = config['chat'] + return { + 'CHAT_COMMENT_MAX_LENGTH': cfg['max_name_length'], + 'CHAT_NAME_MAX_LENGTH': cfg['max_name_length'], + 'CHAT_NAME_MIN_CONTRAST': cfg['min_name_contrast'], + 'CHAT_BACKGROUND_COLOUR': color_to_colour(cfg['background_color']), + 'CHAT_LEGACY_TRIPCODE_ALGORITHM': cfg['legacy_tripcode_algorithm'], + } + +def toml_to_flask_section_flood(config): + cfg = config['flood'] + 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'], + } + +def toml_to_flask_section_captcha(config): + cfg = config['captcha'] + return { + 'CAPTCHA_LIFETIME': cfg['lifetime'], + 'CAPTCHA_FONTS': cfg['fonts'], + 'CAPTCHA_ALPHABET': cfg['alphabet'], + 'CAPTCHA_LENGTH': cfg['length'], + 'CAPTCHA_BACKGROUND_COLOUR': color_to_colour(cfg['background_color']), + 'CAPTCHA_FOREGROUND_COLOUR': color_to_colour(cfg['foreground_color']), + } diff --git a/anonstream/tasks.py b/anonstream/tasks.py index 694b23c..0047620 100644 --- a/anonstream/tasks.py +++ b/anonstream/tasks.py @@ -43,7 +43,7 @@ def with_period(period): return periodically -@with_period(CONFIG['TASK_PERIOD_ROTATE_USERS']) +@with_period(CONFIG['TASK_ROTATE_USERS']) @with_timestamp async def t_sunset_users(timestamp, iteration): if iteration == 0: @@ -69,7 +69,7 @@ async def t_sunset_users(timestamp, iteration): }, ) -@with_period(CONFIG['TASK_PERIOD_ROTATE_CAPTCHAS']) +@with_period(CONFIG['TASK_ROTATE_CAPTCHAS']) async def t_expire_captchas(iteration): if iteration == 0: return @@ -86,10 +86,10 @@ async def t_expire_captchas(iteration): for digest in to_delete: CAPTCHAS.pop(digest) -@with_period(CONFIG['TASK_PERIOD_ROTATE_WEBSOCKETS']) +@with_period(CONFIG['TASK_ROTATE_WEBSOCKETS']) @with_timestamp async def t_close_websockets(timestamp, iteration): - THRESHOLD = CONFIG['TASK_PERIOD_BROADCAST_PING'] * 1.5 + 4.0 + THRESHOLD = CONFIG['TASK_BROADCAST_PING'] * 1.5 + 4.0 if iteration == 0: return else: @@ -100,21 +100,21 @@ async def t_close_websockets(timestamp, iteration): if last_pong_ago > THRESHOLD: queue.put_nowait({'type': 'close'}) -@with_period(CONFIG['TASK_PERIOD_BROADCAST_PING']) +@with_period(CONFIG['TASK_BROADCAST_PING']) async def t_broadcast_ping(iteration): if iteration == 0: return else: broadcast(USERS, payload={'type': 'ping'}) -@with_period(CONFIG['TASK_PERIOD_BROADCAST_USERS_UPDATE']) +@with_period(CONFIG['TASK_BROADCAST_USERS_UPDATE']) async def t_broadcast_users_update(iteration): if iteration == 0: return else: broadcast_users_update() -@with_period(CONFIG['TASK_PERIOD_BROADCAST_STREAM_INFO_UPDATE']) +@with_period(CONFIG['TASK_BROADCAST_STREAM_INFO_UPDATE']) async def t_broadcast_stream_info_update(iteration): if iteration == 0: title = await get_stream_title() @@ -139,7 +139,7 @@ async def t_broadcast_stream_info_update(iteration): else: expected_uptime = ( current_app.stream_uptime - + CONFIG['TASK_PERIOD_BROADCAST_STREAM_INFO_UPDATE'] + + CONFIG['TASK_BROADCAST_STREAM_INFO_UPDATE'] ) current_app.stream_uptime = uptime if uptime is None and expected_uptime is None: diff --git a/anonstream/websocket.py b/anonstream/websocket.py index 6393ee0..38226c8 100644 --- a/anonstream/websocket.py +++ b/anonstream/websocket.py @@ -30,7 +30,7 @@ async def websocket_outbound(queue, user): }, 'scrollback': CONFIG['MAX_CHAT_SCROLLBACK'], 'digest': get_random_captcha_digest_for(user), - 'pingpong': CONFIG['TASK_PERIOD_BROADCAST_PING'], + 'pingpong': CONFIG['TASK_BROADCAST_PING'], } await websocket.send_json(payload) await websocket.send_json({'type': 'ping'})