From 6e8d8dc8e969cdf98139059cc41717d4b2afbbf5 Mon Sep 17 00:00:00 2001 From: n9k Date: Thu, 14 Jul 2022 16:31:11 +0000 Subject: [PATCH] Emotes The sheet of emotes goes in `/static/emotes.png`. Emote coordinates go in emotes.json (by default, there is a config option). --- anonstream/__init__.py | 6 ++ anonstream/chat.py | 4 +- anonstream/config.py | 7 +++ anonstream/helpers/chat.py | 25 +++++++- anonstream/routes/core.py | 4 +- anonstream/routes/nojs.py | 6 +- anonstream/routes/wrappers.py | 15 +++++ anonstream/static/anonstream.js | 63 +++++++++++++++++++- anonstream/static/style.css | 6 ++ anonstream/templates/nojs_chat_messages.html | 17 +++++- anonstream/utils/chat.py | 47 +++++++++++++++ anonstream/websocket.py | 2 + config.toml | 3 + emotes.json | 1 + 14 files changed, 199 insertions(+), 7 deletions(-) create mode 100644 emotes.json diff --git a/anonstream/__init__.py b/anonstream/__init__.py index ad85578..f88f150 100644 --- a/anonstream/__init__.py +++ b/anonstream/__init__.py @@ -1,6 +1,7 @@ # SPDX-FileCopyrightText: 2022 n9k # SPDX-License-Identifier: AGPL-3.0-or-later +import json from collections import OrderedDict from quart_compress import Compress @@ -8,6 +9,7 @@ from quart_compress import Compress from anonstream.config import update_flask_from_toml from anonstream.quart import Quart from anonstream.utils.captcha import create_captcha_factory, create_captcha_signer +from anonstream.utils.chat import schema_to_emotes from anonstream.utils.user import generate_blank_allowedness __version__ = '1.4.0' @@ -45,6 +47,10 @@ def create_app(toml_config): app.failures = OrderedDict() # access captcha failures app.allowedness = generate_blank_allowedness() + with open(app.config['EMOTE_SCHEMA']) as fp: + schema = json.load(fp) + app.emotes = schema_to_emotes(schema) + # State for tasks app.users_update_buffer = set() app.stream_title = None diff --git a/anonstream/chat.py b/anonstream/chat.py index c10fbc0..a097f77 100644 --- a/anonstream/chat.py +++ b/anonstream/chat.py @@ -8,7 +8,7 @@ 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 +from anonstream.helpers.chat import generate_nonce_hash, get_scrollback, insert_emotes from anonstream.utils.chat import get_message_for_websocket, get_approx_linespan CONFIG = current_app.config @@ -93,7 +93,7 @@ def add_chat_message(user, nonce, comment, ignore_empty=False): else: seq = last_message['seq'] + 1 dt = datetime.utcfromtimestamp(timestamp) - markup = escape(comment) + markup = insert_emotes(escape(comment)) message = { 'id': message_id, 'seq': seq, diff --git a/anonstream/config.py b/anonstream/config.py index 8ebda05..e9f842f 100644 --- a/anonstream/config.py +++ b/anonstream/config.py @@ -39,6 +39,7 @@ def toml_to_flask_sections(config): toml_to_flask_section_flood, toml_to_flask_section_captcha, toml_to_flask_section_nojs, + toml_to_flask_section_emote, ) for toml_to_flask_section in TOML_TO_FLASK_SECTIONS: yield toml_to_flask_section(config) @@ -164,3 +165,9 @@ def toml_to_flask_section_nojs(config): 'NOJS_REFRESH_USERS': round(cfg['refresh_users']), 'NOJS_TIMEOUT_CHAT': round(cfg['timeout_chat']), } + +def toml_to_flask_section_emote(config): + cfg = config['emote'] + return { + 'EMOTE_SCHEMA': cfg['schema'], + } diff --git a/anonstream/helpers/chat.py b/anonstream/helpers/chat.py index 3feac2a..279160b 100644 --- a/anonstream/helpers/chat.py +++ b/anonstream/helpers/chat.py @@ -3,9 +3,10 @@ import hashlib -from quart import current_app +from quart import current_app, escape, Markup CONFIG = current_app.config +EMOTES = current_app.emotes def generate_nonce_hash(nonce): parts = CONFIG['SECRET_KEY'] + b'nonce-hash\0' + nonce.encode() @@ -16,3 +17,25 @@ def get_scrollback(messages): if len(messages) < n: return messages return list(messages)[-n:] + +def insert_emotes(markup): + assert isinstance(markup, Markup) + for name, regex, _position, _size in EMOTES: + emote_markup = ( + f'{escape(name)}' + ) + markup = regex.sub(emote_markup, markup) + return Markup(markup) + +def get_emotes_for_websocket(): + return { + name: { + 'x': position[0], + 'y': position[1], + 'width': size[0], + 'height': size[1], + } + for name, _regex, position, size in EMOTES + } + return tuple(EMOTES.values()) diff --git a/anonstream/routes/core.py b/anonstream/routes/core.py index 16f7868..71fc1c6 100644 --- a/anonstream/routes/core.py +++ b/anonstream/routes/core.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: AGPL-3.0-or-later import math +import re from quart import current_app, request, render_template, abort, make_response, redirect, url_for, send_from_directory from werkzeug.exceptions import Forbidden, NotFound, TooManyRequests @@ -11,7 +12,7 @@ from anonstream.captcha import get_captcha_image, get_random_captcha_digest from anonstream.segments import segments, StopSendingSegments from anonstream.stream import is_online, get_stream_uptime from anonstream.user import watching, create_eyes, renew_eyes, EyesException, RatelimitedEyes, TooManyEyes, ensure_allowedness, Blacklisted, SecretClub -from anonstream.routes.wrappers import with_user_from, auth_required, clean_cache_headers, generate_and_add_user +from anonstream.routes.wrappers import with_user_from, auth_required, generate_and_add_user, clean_cache_headers, etag_conditional from anonstream.helpers.captcha import check_captcha_digest, Answer from anonstream.utils.security import generate_csp from anonstream.utils.user import identifying_string @@ -136,6 +137,7 @@ async def access(timestamp, user_or_token): @current_app.route('/static/') @with_user_from(request) +@etag_conditional @clean_cache_headers async def static(timestamp, user, filename): return await send_from_directory(STATIC_DIRECTORY, filename) diff --git a/anonstream/routes/nojs.py b/anonstream/routes/nojs.py index 412ffbe..cf1179f 100644 --- a/anonstream/routes/nojs.py +++ b/anonstream/routes/nojs.py @@ -10,12 +10,13 @@ from anonstream.user import add_state, pop_state, try_change_appearance, update_ from anonstream.routes.wrappers import with_user_from, render_template_with_etag from anonstream.helpers.chat import get_scrollback from anonstream.helpers.user import get_default_name -from anonstream.utils.chat import generate_nonce +from anonstream.utils.chat import generate_nonce, escape_css_string, get_emotehash from anonstream.utils.security import generate_csp from anonstream.utils.user import concatenate_for_notice CONFIG = current_app.config USERS_BY_TOKEN = current_app.users_by_token +EMOTES = current_app.emotes @current_app.route('/stream.html') @with_user_from(request) @@ -53,8 +54,11 @@ async def nojs_chat_messages(timestamp, user): refresh=CONFIG['NOJS_REFRESH_MESSAGES'], user=user, users_by_token=USERS_BY_TOKEN, + emotes=EMOTES, + emotehash=get_emotehash(tuple(EMOTES)), messages=get_scrollback(current_app.messages), timeout=CONFIG['NOJS_TIMEOUT_CHAT'], + escape_css_string=escape_css_string, get_default_name=get_default_name, ) diff --git a/anonstream/routes/wrappers.py b/anonstream/routes/wrappers.py index b7d8f05..7217a46 100644 --- a/anonstream/routes/wrappers.py +++ b/anonstream/routes/wrappers.py @@ -213,6 +213,21 @@ def clean_cache_headers(f): return wrapper +def etag_conditional(f): + @wraps(f) + async def wrapper(*args, **kwargs): + response = await f(*args, **kwargs) + etag = response.headers.get('ETag') + if etag is not None: + if match := re.fullmatch(r'"(?P.+)"', etag): + tag = match.group('tag') + if tag in request.if_none_match: + return '', 304, {'ETag': etag} + + return response + + return wrapper + def assert_allowedness(timestamp, user): try: ensure_allowedness(user, timestamp=timestamp) diff --git a/anonstream/static/anonstream.js b/anonstream/static/anonstream.js index 6a9c9db..40b6c2c 100644 --- a/anonstream/static/anonstream.js +++ b/anonstream/static/anonstream.js @@ -84,6 +84,12 @@ const insert_jsmarkup = () => { style_tripcode_colors.nonce = CSP; document.head.insertAdjacentElement("beforeend", style_tripcode_colors); } + if (document.getElementById("style-emote") === null) { + const style_emote = document.createElement("style"); + style_emote.id = "style-emote"; + style_emote.nonce = CSP; + document.head.insertAdjacentElement("beforeend", style_emote); + } if (document.getElementById("stream__video") === null) { const parent = document.getElementById("stream"); parent.insertAdjacentHTML("beforeend", jsmarkup_stream_video); @@ -134,6 +140,7 @@ insert_jsmarkup(); const stylesheet_color = document.styleSheets[1]; const stylesheet_tripcode_display = document.styleSheets[2]; const stylesheet_tripcode_colors = document.styleSheets[3]; +const stylesheet_emote = document.styleSheets[4]; /* override chat form notice button */ const chat_form = document.getElementById("chat-form_js"); @@ -275,6 +282,57 @@ const delete_chat_messages = (seqs) => { } } +const hexdigest = async (string, bytelength) => { + uint8array = new TextEncoder().encode(string); + arraybuffer = await crypto.subtle.digest('sha-256', uint8array); + array = Array.from(new Uint8Array(arraybuffer).slice(0, bytelength)); + hex = array.map(b => b.toString(16).padStart(2, '0')).join(''); + return hex +} +const escape_css_string = (string) => { + /* https://drafts.csswg.org/cssom/#common-serializing-idioms */ + const result = []; + for (const char of string) { + if (char === '\0') { + result.push('\ufffd'); + } else if (char < '\u0020' || char == '\u007f') { + result.push(`\\${char.charCodeAt().toString(16)}`); + } else if (char == '"' || char == '\\') { + result.push(`\\${char}`); + } else { + result.push(char); + } + } + return result.join(''); +} +const update_emotes = async (emotes) => { + const rules = []; + for (const key of Object.keys(emotes)) { + const emote = emotes[key]; + rules.push( + `[data-emote="${escape_css_string(key)}"] { background-position: ${-emote.x}px ${-emote.y}px; width: ${emote.width}px; height: ${emote.height}px; }` + ); + } + rules.sort(); + const emotehash = await hexdigest(rules.toString(), 6); + const emotehash_rule = `.emote { background-image: url("/static/emotes.png?coords=${escape_css_string(encodeURIComponent(emotehash))}"); }`; + + const rules_set = new Set([emotehash_rule, ...rules]); + const to_delete = []; + for (let index = 0; index < stylesheet_emote.cssRules.length; index++) { + const css_rule = stylesheet_emote.cssRules[index]; + if (!rules_set.delete(css_rule.cssText)) { + to_delete.push(index); + } + } + for (const rule of rules_set) { + stylesheet_emote.insertRule(rule); + } + for (const index of to_delete.reverse()) { + stylesheet_emote.deleteRule(index + rules_set.size); + } +} + let users = {}; let stats = null; let stats_received = null; @@ -594,7 +652,7 @@ const show_offline_screen = () => { stream.dataset.offline = ""; } -const on_websocket_message = (event) => { +const on_websocket_message = async (event) => { //console.log("websocket message", event); const receipt = JSON.parse(event.data); switch (receipt.type) { @@ -676,6 +734,9 @@ const on_websocket_message = (event) => { chat_appearance_form_name.setAttribute("placeholder", default_name[user.broadcaster]); chat_appearance_form_color.setAttribute("value", user.color); + // emote coordinates + await update_emotes(receipt.emotes); + // insert new messages const last = chat_messages.children.length == 0 ? null : chat_messages.children[chat_messages.children.length - 1]; const last_seq = last === null ? null : parseInt(last.dataset.seq); diff --git a/anonstream/static/style.css b/anonstream/static/style.css index 9ee1d60..d48d840 100644 --- a/anonstream/static/style.css +++ b/anonstream/static/style.css @@ -263,6 +263,12 @@ noscript { overflow-wrap: anywhere; line-height: 1.3125; } +.emote { + display: inline-block; + font-size: 0; + vertical-align: middle; + cursor: default; +} .tripcode { padding: 0 5px; border-radius: 7px; diff --git a/anonstream/templates/nojs_chat_messages.html b/anonstream/templates/nojs_chat_messages.html index 0647188..78fc011 100644 --- a/anonstream/templates/nojs_chat_messages.html +++ b/anonstream/templates/nojs_chat_messages.html @@ -8,7 +8,7 @@ - + diff --git a/anonstream/utils/chat.py b/anonstream/utils/chat.py index e0ac7fd..f496852 100644 --- a/anonstream/utils/chat.py +++ b/anonstream/utils/chat.py @@ -4,7 +4,11 @@ import base64 import hashlib import math +import re import secrets +from functools import lru_cache + +from quart import escape class NonceReuse(Exception): pass @@ -26,3 +30,46 @@ def get_approx_linespan(text): linespan = sum(map(height, text.splitlines())) linespan = linespan if linespan > 0 else 1 return linespan + +def schema_to_emotes(schema): + emotes = [] + for name, coords in schema.items(): + assert not re.search(r'\s', name), \ + 'whitespace is not allowed in emote names' + name_markup = escape(name) + regex = re.compile( + r'(?:^|(?<=\s|\W))%s(?:$|(?=\s|\W))' % re.escape(name_markup) + ) + position, size = tuple(coords['position']), tuple(coords['size']) + emotes.append((name, regex, position, size)) + return emotes + +def escape_css_string(string): + ''' + https://drafts.csswg.org/cssom/#common-serializing-idioms + ''' + result = [] + for char in string: + if char == '\0': + result.append('\ufffd') + elif char < '\u0020' or char == '\u007f': + result.append(f'\\{ord(char):x}') + elif char == '"' or char == '\\': + result.append(f'\\{char}') + else: + result.append(char) + return ''.join(result) + +@lru_cache(maxsize=1) +def get_emotehash(emotes): + rules = [] + for name, _regex, (x, y), (width, height) in sorted(emotes): + rule = ( + f'[data-emote="{escape_css_string(name)}"] ' + f'{{ background-position: {-x}px {-y}px; ' + f'width: {width}px; height: {height}px; }}' + ) + rules.append(rule.encode()) + plaintext = b','.join(rules) + digest = hashlib.sha256(plaintext).digest() + return digest[:6].hex() diff --git a/anonstream/websocket.py b/anonstream/websocket.py index a20d393..66bbc11 100644 --- a/anonstream/websocket.py +++ b/anonstream/websocket.py @@ -11,6 +11,7 @@ from anonstream.captcha import get_random_captcha_digest_for from anonstream.chat import get_all_messages_for_websocket, add_chat_message, Rejected from anonstream.user import get_all_users_for_websocket, see, reading, verify, deverify, BadCaptcha, try_change_appearance, ensure_allowedness, AllowednessException from anonstream.wrappers import with_timestamp, get_timestamp +from anonstream.helpers.chat import get_emotes_for_websocket from anonstream.utils.chat import generate_nonce from anonstream.utils.user import identifying_string from anonstream.utils.websocket import parse_websocket_data, Malformed, WS @@ -36,6 +37,7 @@ async def websocket_outbound(queue, user): 'scrollback': CONFIG['MAX_CHAT_SCROLLBACK'], 'digest': get_random_captcha_digest_for(user), 'pingpong': CONFIG['TASK_BROADCAST_PING'], + 'emotes': get_emotes_for_websocket(), }) while True: payload = await queue.get() diff --git a/config.toml b/config.toml index a12737e..0408c5d 100644 --- a/config.toml +++ b/config.toml @@ -89,3 +89,6 @@ refresh_messages = 4.0 refresh_info = 6.0 refresh_users = 6.0 timeout_chat = 30.0 + +[emote] +schema = "emotes.json" diff --git a/emotes.json b/emotes.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/emotes.json @@ -0,0 +1 @@ +{}