diff --git a/README.md b/README.md index 466ff14..8d1fab2 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ using the `ANONSTREAM_CONFIG` environment variable. anonstream has APIs for accessing internal state and hooking into internal events. They can be used by humans and other programs. See -[HACKING.md][/doc/HACKING.md]. +[HACKING.md](/doc/HACKING.md). ## Copying diff --git a/anonstream/__init__.py b/anonstream/__init__.py index abd8b8d..1f514e1 100644 --- a/anonstream/__init__.py +++ b/anonstream/__init__.py @@ -8,9 +8,9 @@ from collections import OrderedDict from quart_compress import Compress from anonstream.config import update_flask_from_toml +from anonstream.emote import load_emote_schema from anonstream.quart import Quart from anonstream.utils.captcha import create_captcha_factory, create_captcha_signer -from anonstream.utils.chat import precompute_emote_regex from anonstream.utils.user import generate_blank_allowedness __version__ = '1.6.4' @@ -49,10 +49,10 @@ def create_app(toml_config): app.allowedness = generate_blank_allowedness() # Read emote schema - with open(app.config['EMOTE_SCHEMA']) as fp: - emotes = json.load(fp) - precompute_emote_regex(emotes) - app.emotes = emotes + try: + app.emotes = load_emote_schema(app.config['EMOTE_SCHEMA']) + except (OSError, json.JSONDecodeError) as e: + raise AssertionError(f'couldn\'t load emote schema: {e!r}') from e # State for tasks app.users_update_buffer = set() diff --git a/anonstream/chat.py b/anonstream/chat.py index 68ceaba..7075411 100644 --- a/anonstream/chat.py +++ b/anonstream/chat.py @@ -8,7 +8,8 @@ from quart import current_app, escape from anonstream.broadcast import broadcast, broadcast_users_update from anonstream.events import notify_event_sockets -from anonstream.helpers.chat import generate_nonce_hash, get_scrollback, insert_emotes +from anonstream.helpers.chat import generate_nonce_hash, get_scrollback +from anonstream.helpers.emote import insert_emotes from anonstream.utils.chat import get_message_for_websocket, get_approx_linespan CONFIG = current_app.config diff --git a/anonstream/control/parse.py b/anonstream/control/parse.py index a567c6d..7acf77f 100644 --- a/anonstream/control/parse.py +++ b/anonstream/control/parse.py @@ -5,18 +5,20 @@ from anonstream.control.spec import ParseException, Parsed from anonstream.control.spec.common import Str from anonstream.control.spec.methods.allowedness import SPEC as SPEC_ALLOWEDNESS from anonstream.control.spec.methods.chat import SPEC as SPEC_CHAT -from anonstream.control.spec.methods.exit import SPEC as SPEC_EXIT +from anonstream.control.spec.methods.emote import SPEC as SPEC_EMOTE from anonstream.control.spec.methods.help import SPEC as SPEC_HELP +from anonstream.control.spec.methods.quit import SPEC as SPEC_QUIT from anonstream.control.spec.methods.title import SPEC as SPEC_TITLE from anonstream.control.spec.methods.user import SPEC as SPEC_USER SPEC = Str({ 'help': SPEC_HELP, - 'exit': SPEC_EXIT, + 'quit': SPEC_QUIT, 'title': SPEC_TITLE, 'chat': SPEC_CHAT, 'user': SPEC_USER, 'allowednesss': SPEC_ALLOWEDNESS, + 'emote': SPEC_EMOTE, }) async def parse(request): diff --git a/anonstream/control/spec/methods/emote.py b/anonstream/control/spec/methods/emote.py new file mode 100644 index 0000000..1389d74 --- /dev/null +++ b/anonstream/control/spec/methods/emote.py @@ -0,0 +1,61 @@ +# SPDX-FileCopyrightText: 2022 n9k +# SPDX-License-Identifier: AGPL-3.0-or-later + +import json + +from quart import current_app + +from anonstream.emote import load_emote_schema_async, BadEmote +from anonstream.helpers.emote import get_emote_markup +from anonstream.control.spec.common import Str, End +from anonstream.control.exceptions import CommandFailed + +CONFIG = current_app.config +EMOTES = current_app.emotes + +async def cmd_emote_help(): + normal = ['emote', 'help'] + response = ( + 'Usage: emote [show | reload]\n' + 'Commands:\n' + ' emote show........show all current emotes\n' + ' emote reload......try to reload the emote schema (existing messages are not modified)\n' + ) + return normal, response + +async def cmd_emote_show(): + emotes_for_json = [emote.copy() for emote in EMOTES] + for emote in emotes_for_json: + emote['regex'] = emote['regex'].pattern + normal = ['emote', 'show'] + response = json.dumps(emotes_for_json) + '\n' + return normal, response + +async def cmd_emote_reload(): + try: + emotes = await load_emote_schema_async(CONFIG['EMOTE_SCHEMA']) + except OSError as e: + raise CommandFailed(f'could not read emote schema: {e}') from e + except json.JSONDecodeError as e: + raise CommandFailed('could not decode emote schema as json') from e + except BadEmote as e: + error, *_ = e.args + raise CommandFailed(error) from e + else: + # Mutate current_app.emotes in place + EMOTES.clear() + for emote in emotes: + EMOTES.append(emote) + # Clear emote markup cache -- emotes by the same name may have changed + get_emote_markup.cache_clear() + normal = ['emote', 'reload'] + response = '' + return normal, response + +SPEC = Str({ + None: End(cmd_emote_help), + 'help': End(cmd_emote_help), + 'show': End(cmd_emote_show), + 'reload': End(cmd_emote_reload), +}) + diff --git a/anonstream/control/spec/methods/help.py b/anonstream/control/spec/methods/help.py index 8a5399f..d38a322 100644 --- a/anonstream/control/spec/methods/help.py +++ b/anonstream/control/spec/methods/help.py @@ -9,7 +9,7 @@ async def cmd_help(): 'Usage: METHOD [COMMAND | help]\n' 'Examples:\n' ' help...........................show this help message\n' - ' exit...........................close the control connection\n' + ' quit...........................close the control connection\n' ' title [show]...................show the stream title\n' ' title set TITLE................set the stream title\n' ' user [show]....................show a list of users\n' @@ -24,6 +24,8 @@ async def cmd_help(): ' allowedness setdefault BOOLEAN.set the default allowedness\n' ' allowedness add SET STRING.....add to the blacklist/whitelist\n' ' allowedness remove SET STRING..remove from the blacklist/whitelist\n' + ' emote show.....................show all current emotes\n' + ' emote reload...................try reloading the emote schema\n' ) return normal, response diff --git a/anonstream/control/spec/methods/exit.py b/anonstream/control/spec/methods/quit.py similarity index 62% rename from anonstream/control/spec/methods/exit.py rename to anonstream/control/spec/methods/quit.py index 101f104..1f7651b 100644 --- a/anonstream/control/spec/methods/exit.py +++ b/anonstream/control/spec/methods/quit.py @@ -4,19 +4,19 @@ from anonstream.control.spec.common import Str, End from anonstream.control.exceptions import ControlSocketExit -async def cmd_exit(): +async def cmd_quit(): raise ControlSocketExit -async def cmd_exit_help(): - normal = ['exit', 'help'] +async def cmd_quit_help(): + normal = ['quit', 'help'] response = ( - 'Usage: exit\n' + 'Usage: quit\n' 'Commands:\n' - ' exit......close the connection\n' + ' quit......close the connection\n' ) return normal, response SPEC = Str({ - None: End(cmd_exit), - 'help': End(cmd_exit_help), + None: End(cmd_quit), + 'help': End(cmd_quit_help), }) diff --git a/anonstream/emote.py b/anonstream/emote.py new file mode 100644 index 0000000..ab7bb1d --- /dev/null +++ b/anonstream/emote.py @@ -0,0 +1,55 @@ +import json +import re + +import aiofiles +from quart import escape + +class BadEmote(Exception): + pass + +class BadEmoteName(BadEmote): + pass + +def _load_emote_schema(emotes): + for key in ('name', 'file', 'width', 'height'): + for emote in emotes: + if key not in emote: + raise BadEmote(f'emotes must have a `{key}`: {emote}') + precompute_emote_regex(emotes) + return emotes + +def load_emote_schema(filepath): + with open(filepath) as fp: + emotes = json.load(fp) + return _load_emote_schema(emotes) + +async def load_emote_schema_async(filepath): + async with aiofiles.open(filepath) as fp: + data = await fp.read(8192) + return _load_emote_schema(json.loads(data)) + +def precompute_emote_regex(schema): + for emote in schema: + if not emote['name']: + raise BadEmoteName(f'emote names cannot be empty: {emote}') + if re.search(r'\s', emote['name']): + raise BadEmoteName( + f'whitespace is not allowed in emote names: {emote["name"]!r}' + ) + for length in (emote['width'], emote['height']): + if length is not None and (not isinstance(length, int) or length < 0): + raise BadEmoteName( + f'emote dimensions must be null or non-negative integers: ' + f'{emote}' + ) + # If the emote name begins with a word character [a-zA-Z0-9_], + # match only if preceded by a non-word character or the empty + # string. Similarly for the end of the emote name. + # Examples: + # * ":joy:" matches "abc :joy:~xyz" and "abc:joy:xyz" + # * "JoySi" matches "abc JoySi~xyz" but NOT "abcJoySiabc" + onset = r'(?:^|(?<=\W))' if re.fullmatch(r'\w', emote['name'][0]) else r'' + finish = r'(?:$|(?=\W))' if re.fullmatch(r'\w', emote['name'][-1]) else r'' + emote['regex'] = re.compile(''.join( + (onset, re.escape(escape(emote['name'])), finish) + )) diff --git a/anonstream/helpers/chat.py b/anonstream/helpers/chat.py index 0ecc46c..3feac2a 100644 --- a/anonstream/helpers/chat.py +++ b/anonstream/helpers/chat.py @@ -2,13 +2,10 @@ # SPDX-License-Identifier: AGPL-3.0-or-later import hashlib -from functools import lru_cache -import markupsafe -from quart import current_app, escape, url_for, Markup +from quart import current_app CONFIG = current_app.config -EMOTES = current_app.emotes def generate_nonce_hash(nonce): parts = CONFIG['SECRET_KEY'] + b'nonce-hash\0' + nonce.encode() @@ -19,24 +16,3 @@ def get_scrollback(messages): if len(messages) < n: return messages return list(messages)[-n:] - -@lru_cache -def get_emote_markup(emote_name, emote_file, emote_width, emote_height): - emote_name_markup = escape(emote_name) - width = '' if emote_width is None else f'width="{escape(emote_width)}" ' - height = '' if emote_height is None else f'height="{escape(emote_height)}" ' - return Markup( - f'''''' - ) - -def insert_emotes(markup): - assert isinstance(markup, markupsafe.Markup) - for emote in EMOTES: - emote_markup = get_emote_markup( - emote['name'], emote['file'], emote['width'], emote['height'], - ) - markup = emote['regex'].sub(emote_markup, markup) - return Markup(markup) diff --git a/anonstream/helpers/emote.py b/anonstream/helpers/emote.py new file mode 100644 index 0000000..47dcaba --- /dev/null +++ b/anonstream/helpers/emote.py @@ -0,0 +1,27 @@ +import markupsafe +from functools import lru_cache + +from quart import current_app, escape, url_for, Markup + +EMOTES = current_app.emotes + +@lru_cache +def get_emote_markup(emote_name, emote_file, emote_width, emote_height): + emote_name_markup = escape(emote_name) + width = '' if emote_width is None else f'width="{escape(emote_width)}" ' + height = '' if emote_height is None else f'height="{escape(emote_height)}" ' + return Markup( + f'''''' + ) + +def insert_emotes(markup): + assert isinstance(markup, markupsafe.Markup) + for emote in EMOTES: + emote_markup = get_emote_markup( + emote['name'], emote['file'], emote['width'], emote['height'], + ) + markup = emote['regex'].sub(emote_markup, markup) + return Markup(markup) diff --git a/anonstream/utils/chat.py b/anonstream/utils/chat.py index 916a49c..39ff75f 100644 --- a/anonstream/utils/chat.py +++ b/anonstream/utils/chat.py @@ -30,23 +30,3 @@ def get_approx_linespan(text): linespan = sum(map(height, text.splitlines())) linespan = linespan if linespan > 0 else 1 return linespan - -def precompute_emote_regex(schema): - for emote in schema: - assert emote['name'], 'emote names cannot be empty' - assert not re.search(r'\s', emote['name']), \ - f'whitespace is not allowed in emote names: {emote["name"]!r}' - for length in (emote['width'], emote['height']): - assert length is None or isinstance(length, int) and length >= 0, \ - f'emote dimensions must be null or non-negative integers: {emote["name"]!r}' - # If the emote name begins with a word character [a-zA-Z0-9_], - # match only if preceded by a non-word character or the empty - # string. Similarly for the end of the emote name. - # Examples: - # * ":joy:" matches "abc :joy:~xyz" and "abc:joy:xyz" - # * "JoySi" matches "abc JoySi~xyz" but NOT "abcJoySiabc" - onset = r'(?:^|(?<=\W))' if re.fullmatch(r'\w', emote['name'][0]) else r'' - finish = r'(?:$|(?=\W))' if re.fullmatch(r'\w', emote['name'][-1]) else r'' - emote['regex'] = re.compile(''.join( - (onset, re.escape(escape(emote['name'])), finish) - ))