Emotes: one emote, one file
このコミットが含まれているのは:
コミット
208ef9abc7
|
@ -9,7 +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.chat import precompute_emote_regex
|
||||
from anonstream.utils.user import generate_blank_allowedness
|
||||
|
||||
__version__ = '1.5.5'
|
||||
|
@ -47,9 +47,11 @@ def create_app(toml_config):
|
|||
app.failures = OrderedDict() # access captcha failures
|
||||
app.allowedness = generate_blank_allowedness()
|
||||
|
||||
# Read emote schema
|
||||
with open(app.config['EMOTE_SCHEMA']) as fp:
|
||||
schema = json.load(fp)
|
||||
app.emotes = schema_to_emotes(schema)
|
||||
emotes = json.load(fp)
|
||||
precompute_emote_regex(emotes)
|
||||
app.emotes = emotes
|
||||
|
||||
# State for tasks
|
||||
app.users_update_buffer = set()
|
||||
|
|
|
@ -169,6 +169,5 @@ def toml_to_flask_section_nojs(config):
|
|||
def toml_to_flask_section_emote(config):
|
||||
cfg = config['emote']
|
||||
return {
|
||||
'EMOTE_SHEET': cfg['sheet'],
|
||||
'EMOTE_SCHEMA': cfg['schema'],
|
||||
}
|
||||
|
|
|
@ -2,9 +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, Markup
|
||||
from quart import current_app, escape, url_for, Markup
|
||||
|
||||
CONFIG = current_app.config
|
||||
EMOTES = current_app.emotes
|
||||
|
@ -19,24 +20,21 @@ def get_scrollback(messages):
|
|||
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)
|
||||
return Markup(
|
||||
f'''<img class="emote" '''
|
||||
f'''src="{url_for('static', filename=emote_file)}" '''
|
||||
f'''width="{escape(emote_width)}" height="{escape(emote_height)}" '''
|
||||
f'''alt="{emote_name_markup}" title="{emote_name_markup}">'''
|
||||
)
|
||||
|
||||
def insert_emotes(markup):
|
||||
assert isinstance(markup, markupsafe.Markup)
|
||||
for name, regex, _position, _size in EMOTES:
|
||||
emote_markup = (
|
||||
f'<span class="emote" data-emote="{escape(name)}" '
|
||||
f'title="{escape(name)}">{escape(name)}</span>'
|
||||
for emote in EMOTES:
|
||||
emote_markup = get_emote_markup(
|
||||
emote['name'], emote['file'], emote['width'], emote['height'],
|
||||
)
|
||||
markup = regex.sub(emote_markup, markup)
|
||||
markup = emote['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())
|
||||
|
|
|
@ -135,7 +135,7 @@ async def access(timestamp, user_or_token):
|
|||
url = url_for('home', token=user['token'])
|
||||
return redirect(url, 303)
|
||||
|
||||
@current_app.route('/static/<filename>')
|
||||
@current_app.route('/static/<path:filename>')
|
||||
@with_user_from(request)
|
||||
@etag_conditional
|
||||
@clean_cache_headers
|
||||
|
|
|
@ -10,13 +10,12 @@ 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, escape_css_string, get_emotehash
|
||||
from anonstream.utils.chat import generate_nonce
|
||||
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)
|
||||
|
@ -54,12 +53,8 @@ async def nojs_chat_messages(timestamp, user):
|
|||
refresh=CONFIG['NOJS_REFRESH_MESSAGES'],
|
||||
user=user,
|
||||
users_by_token=USERS_BY_TOKEN,
|
||||
emotes=EMOTES,
|
||||
emotesheet=CONFIG['EMOTE_SHEET'],
|
||||
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,
|
||||
)
|
||||
|
||||
|
|
|
@ -84,12 +84,6 @@ 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);
|
||||
|
@ -140,7 +134,6 @@ 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");
|
||||
|
@ -305,39 +298,11 @@ const escape_css_string = (string) => {
|
|||
}
|
||||
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/${escape_css_string(escape(emotesheet))}?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;
|
||||
let default_name = {true: "Broadcaster", false: "Anonymous"};
|
||||
let emotesheet = "emotes.png";
|
||||
let max_chat_scrollback = 256;
|
||||
let pingpong_period = 8.0;
|
||||
let ping = null;
|
||||
|
@ -735,10 +700,6 @@ const on_websocket_message = async (event) => {
|
|||
chat_appearance_form_name.setAttribute("placeholder", default_name[user.broadcaster]);
|
||||
chat_appearance_form_color.setAttribute("value", user.color);
|
||||
|
||||
// emotes
|
||||
emotesheet = receipt.emotesheet;
|
||||
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);
|
||||
|
|
|
@ -264,10 +264,7 @@ noscript {
|
|||
line-height: 1.3125;
|
||||
}
|
||||
.emote {
|
||||
display: inline-block;
|
||||
font-size: 0;
|
||||
vertical-align: middle;
|
||||
cursor: default;
|
||||
}
|
||||
.tripcode {
|
||||
padding: 0 5px;
|
||||
|
|
|
@ -134,11 +134,7 @@
|
|||
line-height: 1.3125;
|
||||
}
|
||||
.emote {
|
||||
background-image: url("{{ escape_css_string(url_for('static', filename=emotesheet, coords=emotehash)) | safe }}");
|
||||
display: inline-block;
|
||||
font-size: 0;
|
||||
vertical-align: middle;
|
||||
cursor: default;
|
||||
}
|
||||
.tripcode {
|
||||
padding: 0 5px;
|
||||
|
@ -161,14 +157,6 @@
|
|||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
|
||||
{% for name, _regex, (x, y), (width, height) in emotes %}
|
||||
[data-emote="{{ escape_css_string(name) | safe }}"] {
|
||||
background-position: {{ -x }}px {{ -y }}px;
|
||||
width: {{ width }}px;
|
||||
height: {{ height }}px;
|
||||
}
|
||||
{% endfor %}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
@ -31,52 +31,19 @@ def get_approx_linespan(text):
|
|||
linespan = linespan if linespan > 0 else 1
|
||||
return linespan
|
||||
|
||||
def schema_to_emotes(schema):
|
||||
emotes = []
|
||||
for name, coords in schema.items():
|
||||
def precompute_emote_regex(schema):
|
||||
for emote in schema:
|
||||
assert emote['name'], 'emote names cannot be empty'
|
||||
assert not re.search(r'\s', name), \
|
||||
assert not re.search(r'\s', emote['name']), \
|
||||
'whitespace is not allowed in emote names'
|
||||
name_markup = escape(name)
|
||||
# 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', name[0]) else r''
|
||||
finish = r'(?:$|(?=\W))' if re.fullmatch(r'\w', name[-1]) else r''
|
||||
regex = re.compile(''.join((onset, re.escape(name_markup), finish)))
|
||||
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()
|
||||
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)
|
||||
))
|
||||
|
|
|
@ -11,7 +11,6 @@ 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
|
||||
|
@ -37,8 +36,6 @@ 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(),
|
||||
'emotesheet': CONFIG['EMOTE_SHEET'],
|
||||
})
|
||||
while True:
|
||||
payload = await queue.get()
|
||||
|
|
|
@ -91,5 +91,4 @@ refresh_users = 6.0
|
|||
timeout_chat = 30.0
|
||||
|
||||
[emote]
|
||||
sheet = "emotes.png"
|
||||
schema = "emotes.json"
|
||||
|
|
読み込み中…
新しいイシューから参照