コミットを比較

...

12 コミット

作成者 SHA1 メッセージ 日付
n9k f538410016 v1.6.1 2022-07-20 07:57:18 +00:00
n9k 7f1c4b3fcd Merge branch 'dev' 2022-07-20 07:57:10 +00:00
n9k b1f5bbdecd Force absent users to do the access captcha again
Before this, if a request was not coming from an existing user (no token
in the request or no user with the given token), then and only then
would we send the access captcha.  This meant that if a user left a chat
message and became absent, they wouldn't be prompted to do the access
captcha again until their message was eventuallly rotated.  (While
messages exist we don't delete the users who posted them.)

This commit makes it so if user['verified'] is None, the user is kicked
and prompted with the access captcha.  This is automatically done for
absent users by a background task.
2022-07-20 07:55:32 +00:00
n9k 03887f4a63 v1.6.0 2022-07-20 07:38:08 +00:00
n9k 96e78f2754 Merge branch 'dev' 2022-07-20 07:37:45 +00:00
n9k f36840a9a6 Typos 2022-07-20 07:37:35 +00:00
n9k c93afdeccf Minor CSS: increase input padding on captcha page 2022-07-20 07:37:35 +00:00
n9k 2df92bb488 Minor formatting 2022-07-20 07:37:35 +00:00
n9k 208ef9abc7 Emotes: one emote, one file 2022-07-20 07:37:33 +00:00
n9k b46b3c88d5 v1.5.5 2022-07-20 07:36:45 +00:00
n9k 2a814c9816 Merge commit 'ab0ba51' 2022-07-20 07:36:18 +00:00
n9k ab0ba513bf Emotes: emotes must have non-empty names 2022-07-20 07:36:06 +00:00
15個のファイルの変更71行の追加155行の削除

ファイルの表示

@ -9,10 +9,10 @@ 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.4'
__version__ = '1.6.1'
def create_app(toml_config):
app = Quart('anonstream', static_folder=None)
@ -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,
)

ファイルの表示

@ -137,6 +137,7 @@ def with_user_from(context, fallback_to_token=False, ignore_allowedness=False):
user['headers'] = tuple(context.headers)
if not ignore_allowedness:
assert_allowedness(timestamp, user)
if user is not None and user['verified'] is not None:
response = await f(timestamp, user, *args, **kwargs)
elif fallback_to_token:
#assert not broadcaster

ファイルの表示

@ -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;
@ -724,7 +689,7 @@ const on_websocket_message = async (event) => {
left: 0,
top: chat_messages.scrollTopMax,
behavior: "instant",
});
});
}
// appearance form default values
@ -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;

ファイルの表示

@ -64,9 +64,13 @@ async def t_sunset_users(timestamp, iteration):
if iteration == 0:
return
# Deverify absent users
for user in get_absent_users(timestamp):
user['verified'] = False
# De-access absent users
absent_users = tuple(get_absent_users(timestamp))
for user in absent_users:
user['verified'] = None
# Absent users should have no connected websockets,
# so in normal operation this should always be a no-op
broadcast(users=absent_users, payload={'type': 'kick'})
# Remove as many absent users as possible

ファイルの表示

@ -33,7 +33,7 @@
border-radius: 2px;
color: #ddd;
font-size: 14pt;
padding: 1px 3px;
padding: 4px 5px;
width: 10ch;
}
input[name="answer"]:hover {

ファイルの表示

@ -50,7 +50,7 @@
margin: 24pt 16pt;
}
}
@media (min-width: 400px) and (min-height: 128px;) {
@media (min-width: 400px) and (min-height: 128px) {
body {
background-color: #18181a;
}

ファイルの表示

@ -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,51 +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():
assert not re.search(r'\s', name), \
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']), \
'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()
@ -53,11 +50,15 @@ async def websocket_outbound(queue, user):
try:
ensure_allowedness(user)
except AllowednessException:
websocket.send_json({'type': 'kick'})
await websocket.send_json({'type': 'kick'})
await websocket.close(1001)
break
else:
await websocket.send_json(payload)
if user['verified'] is None:
await websocket.send_json({'type': 'kick'})
await websocket.close(1001)
else:
await websocket.send_json(payload)
async def websocket_inbound(queue, user):
while True:
@ -76,25 +77,28 @@ async def websocket_inbound(queue, user):
except AllowednessException:
payload = {'type': 'kick'}
else:
try:
receipt_type, parsed = parse_websocket_data(receipt)
except Malformed as e:
error , *_ = e.args
payload = {
'type': 'error',
'because': error,
}
if user['verified'] is None:
payload = {'type': 'kick'}
else:
match receipt_type:
case WS.MESSAGE:
handle = handle_inbound_message
case WS.APPEARANCE:
handle = handle_inbound_appearance
case WS.CAPTCHA:
handle = handle_inbound_captcha
case WS.PONG:
handle = handle_inbound_pong
payload = handle(timestamp, queue, user, *parsed)
try:
receipt_type, parsed = parse_websocket_data(receipt)
except Malformed as e:
error , *_ = e.args
payload = {
'type': 'error',
'because': error,
}
else:
match receipt_type:
case WS.MESSAGE:
handle = handle_inbound_message
case WS.APPEARANCE:
handle = handle_inbound_appearance
case WS.CAPTCHA:
handle = handle_inbound_captcha
case WS.PONG:
handle = handle_inbound_pong
payload = handle(timestamp, queue, user, *parsed)
# Write to websocket
if payload is not None:

ファイルの表示

@ -91,5 +91,4 @@ refresh_users = 6.0
timeout_chat = 30.0
[emote]
sheet = "emotes.png"
schema = "emotes.json"