Merge branch 'dev'

このコミットが含まれているのは:
n9k 2022-08-01 02:55:06 +00:00
コミット 777448d83a
11個のファイルの変更166行の追加62行の削除

ファイルの表示

@ -96,7 +96,7 @@ using the `ANONSTREAM_CONFIG` environment variable.
anonstream has APIs for accessing internal state and hooking into anonstream has APIs for accessing internal state and hooking into
internal events. They can be used by humans and other programs. See internal events. They can be used by humans and other programs. See
[HACKING.md][/doc/HACKING.md]. [HACKING.md](/doc/HACKING.md).
## Copying ## Copying

ファイルの表示

@ -8,9 +8,9 @@ from collections import OrderedDict
from quart_compress import Compress from quart_compress import Compress
from anonstream.config import update_flask_from_toml from anonstream.config import update_flask_from_toml
from anonstream.emote import load_emote_schema
from anonstream.quart import Quart from anonstream.quart import Quart
from anonstream.utils.captcha import create_captcha_factory, create_captcha_signer 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 from anonstream.utils.user import generate_blank_allowedness
__version__ = '1.6.4' __version__ = '1.6.4'
@ -49,10 +49,10 @@ def create_app(toml_config):
app.allowedness = generate_blank_allowedness() app.allowedness = generate_blank_allowedness()
# Read emote schema # Read emote schema
with open(app.config['EMOTE_SCHEMA']) as fp: try:
emotes = json.load(fp) app.emotes = load_emote_schema(app.config['EMOTE_SCHEMA'])
precompute_emote_regex(emotes) except (OSError, json.JSONDecodeError) as e:
app.emotes = emotes raise AssertionError(f'couldn\'t load emote schema: {e!r}') from e
# State for tasks # State for tasks
app.users_update_buffer = set() app.users_update_buffer = set()

ファイルの表示

@ -8,7 +8,8 @@ from quart import current_app, escape
from anonstream.broadcast import broadcast, broadcast_users_update from anonstream.broadcast import broadcast, broadcast_users_update
from anonstream.events import notify_event_sockets 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 from anonstream.utils.chat import get_message_for_websocket, get_approx_linespan
CONFIG = current_app.config CONFIG = current_app.config

ファイルの表示

@ -5,18 +5,20 @@ from anonstream.control.spec import ParseException, Parsed
from anonstream.control.spec.common import Str from anonstream.control.spec.common import Str
from anonstream.control.spec.methods.allowedness import SPEC as SPEC_ALLOWEDNESS 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.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.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.title import SPEC as SPEC_TITLE
from anonstream.control.spec.methods.user import SPEC as SPEC_USER from anonstream.control.spec.methods.user import SPEC as SPEC_USER
SPEC = Str({ SPEC = Str({
'help': SPEC_HELP, 'help': SPEC_HELP,
'exit': SPEC_EXIT, 'quit': SPEC_QUIT,
'title': SPEC_TITLE, 'title': SPEC_TITLE,
'chat': SPEC_CHAT, 'chat': SPEC_CHAT,
'user': SPEC_USER, 'user': SPEC_USER,
'allowednesss': SPEC_ALLOWEDNESS, 'allowednesss': SPEC_ALLOWEDNESS,
'emote': SPEC_EMOTE,
}) })
async def parse(request): async def parse(request):

61
anonstream/control/spec/methods/emote.py ノーマルファイル
ファイルの表示

@ -0,0 +1,61 @@
# SPDX-FileCopyrightText: 2022 n9k <https://git.076.ne.jp/ninya9k>
# 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),
})

ファイルの表示

@ -9,7 +9,7 @@ async def cmd_help():
'Usage: METHOD [COMMAND | help]\n' 'Usage: METHOD [COMMAND | help]\n'
'Examples:\n' 'Examples:\n'
' help...........................show this help message\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 [show]...................show the stream title\n'
' title set TITLE................set the stream title\n' ' title set TITLE................set the stream title\n'
' user [show]....................show a list of users\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 setdefault BOOLEAN.set the default allowedness\n'
' allowedness add SET STRING.....add to the blacklist/whitelist\n' ' allowedness add SET STRING.....add to the blacklist/whitelist\n'
' allowedness remove SET STRING..remove from 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 return normal, response

ファイルの表示

@ -4,19 +4,19 @@
from anonstream.control.spec.common import Str, End from anonstream.control.spec.common import Str, End
from anonstream.control.exceptions import ControlSocketExit from anonstream.control.exceptions import ControlSocketExit
async def cmd_exit(): async def cmd_quit():
raise ControlSocketExit raise ControlSocketExit
async def cmd_exit_help(): async def cmd_quit_help():
normal = ['exit', 'help'] normal = ['quit', 'help']
response = ( response = (
'Usage: exit\n' 'Usage: quit\n'
'Commands:\n' 'Commands:\n'
' exit......close the connection\n' ' quit......close the connection\n'
) )
return normal, response return normal, response
SPEC = Str({ SPEC = Str({
None: End(cmd_exit), None: End(cmd_quit),
'help': End(cmd_exit_help), 'help': End(cmd_quit_help),
}) })

55
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)
))

ファイルの表示

@ -2,13 +2,10 @@
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
import hashlib import hashlib
from functools import lru_cache
import markupsafe from quart import current_app
from quart import current_app, escape, url_for, Markup
CONFIG = current_app.config CONFIG = current_app.config
EMOTES = current_app.emotes
def generate_nonce_hash(nonce): def generate_nonce_hash(nonce):
parts = CONFIG['SECRET_KEY'] + b'nonce-hash\0' + nonce.encode() parts = CONFIG['SECRET_KEY'] + b'nonce-hash\0' + nonce.encode()
@ -19,24 +16,3 @@ def get_scrollback(messages):
if len(messages) < n: if len(messages) < n:
return messages return messages
return list(messages)[-n:] 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'''<img class="emote" '''
f'''src="{escape(url_for('static', filename=emote_file))}" '''
f'''{width}{height}'''
f'''alt="{emote_name_markup}" title="{emote_name_markup}">'''
)
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)

27
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'''<img class="emote" '''
f'''src="{escape(url_for('static', filename=emote_file))}" '''
f'''{width}{height}'''
f'''alt="{emote_name_markup}" title="{emote_name_markup}">'''
)
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)

ファイルの表示

@ -30,23 +30,3 @@ def get_approx_linespan(text):
linespan = sum(map(height, text.splitlines())) linespan = sum(map(height, text.splitlines()))
linespan = linespan if linespan > 0 else 1 linespan = linespan if linespan > 0 else 1
return linespan 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)
))