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
internal events. They can be used by humans and other programs. See
[HACKING.md][/doc/HACKING.md].
[HACKING.md](/doc/HACKING.md).
## Copying

ファイルの表示

@ -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()

ファイルの表示

@ -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

ファイルの表示

@ -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):

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'
'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

ファイルの表示

@ -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),
})

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
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'''<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 = 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)
))