one giant veiny commit

このコミットが含まれているのは:
n9k 2022-07-28 10:48:33 +00:00
コミット a1862b9080
23個のファイルの変更461行の追加142行の削除

ファイルの表示

@ -3,6 +3,7 @@
import asyncio
import json
import os
from collections import OrderedDict
from quart_compress import Compress
@ -11,6 +12,7 @@ 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.locale import validate_locale, Nonconforming
from anonstream.utils.user import generate_blank_allowedness
__version__ = '1.6.6'
@ -54,6 +56,22 @@ def create_app(toml_config):
except (OSError, json.JSONDecodeError) as e:
raise AssertionError(f'couldn\'t load emote schema: {e!r}') from e
# Read locales
app.locales = {}
for lang in app.config['LOCALE_OFFERED']:
filepath = os.path.join(app.config['LOCALE_DIRECTORY'], f'{lang}.json')
with open(filepath) as fp:
locale = json.load(fp)
try:
validate_locale(locale)
except Nonconforming as e:
error, *_ = e.args
assert False, f'error in locale {lang!r}: {error}'
else:
app.locales[lang] = locale
app.lang = app.config['LOCALE_DEFAULT']
app.locales[None] = app.locales[app.lang]
# State for tasks
app.users_update_buffer = set()
app.stream_title = None

ファイルの表示

@ -50,34 +50,24 @@ def add_chat_message(user, nonce, comment, ignore_empty=False):
user['linespan'],
))
if total_recent_linespan > CONFIG['FLOOD_LINE_THRESHOLD']:
raise Rejected(
f'Chat overuse in the last '
f'{CONFIG["FLOOD_LINE_DURATION"]:.0f} seconds'
)
raise Rejected('message_ratelimited', CONFIG['FLOOD_LINE_THRESHOLD'])
# Check message
message_id = generate_nonce_hash(nonce)
if message_id in MESSAGES_BY_ID:
raise Rejected('Discarded suspected duplicate message')
raise Rejected('message_suspected_duplicate')
if len(comment) == 0:
raise Rejected('Message was empty')
raise Rejected('message_empty')
if len(comment.strip()) == 0:
raise Rejected('Message was practically empty')
raise Rejected('message_practically_empty')
if len(comment) > CONFIG['CHAT_COMMENT_MAX_LENGTH']:
raise Rejected(
f'Message exceeded {CONFIG["CHAT_COMMENT_MAX_LENGTH"]} chars'
)
raise Rejected('message_too_long', CONFIG['CHAT_COMMENT_MAX_LENGTH'])
if comment.count('\n') + 1 > CONFIG['CHAT_COMMENT_MAX_LINES']:
raise Rejected(
f'Message exceeded {CONFIG["CHAT_COMMENT_MAX_LINES"]} lines'
)
raise Rejected('message_too_many_lines', CONFIG['CHAT_COMMENT_MAX_LINES'])
linespan = get_approx_linespan(comment)
if linespan > CONFIG['CHAT_COMMENT_MAX_LINES']:
raise Rejected(
f'Message would span {CONFIG["CHAT_COMMENT_MAX_LINES"]} '
f'or more lines'
)
raise Rejected('message_too_many_apparent_lines', CONFIG['CHAT_COMMENT_MAX_LINES'])
# Record linespan
linespan_tuple = (timestamp, linespan)

ファイルの表示

@ -40,6 +40,7 @@ def toml_to_flask_sections(config):
toml_to_flask_section_captcha,
toml_to_flask_section_nojs,
toml_to_flask_section_emote,
toml_to_flask_section_locale,
)
for toml_to_flask_section in TOML_TO_FLASK_SECTIONS:
yield toml_to_flask_section(config)
@ -171,3 +172,12 @@ def toml_to_flask_section_emote(config):
return {
'EMOTE_SCHEMA': cfg['schema'],
}
def toml_to_flask_section_locale(config):
cfg = config['locale']
assert cfg['default'] in cfg['offered']
return {
'LOCALE_DEFAULT': cfg['default'],
'LOCALE_OFFERED': cfg['offered'],
'LOCALE_DIRECTORY': cfg['directory'],
}

18
anonstream/locale.py ノーマルファイル
ファイルの表示

@ -0,0 +1,18 @@
from quart import current_app
LOCALES = current_app.locales
def get_lang_and_locale_from(context):
lang = context.args.get('lang')
locale = LOCALES.get(lang)
if locale is None:
lang, locale = None, LOCALES[None]
return lang, locale
def get_lang_from(context):
lang, locale = get_lang_and_locale_from(context)
return lang
def get_locale_from(context):
lang, locale = get_lang_and_locale_from(context)
return locale

ファイルの表示

@ -9,6 +9,7 @@ from werkzeug.exceptions import Forbidden, NotFound, TooManyRequests
from anonstream.access import add_failure, pop_failure
from anonstream.captcha import get_captcha_image, get_random_captcha_digest
from anonstream.locale import get_lang_and_locale_from, get_lang_from, get_locale_from
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
@ -20,33 +21,39 @@ from anonstream.wrappers import with_timestamp
CAPTCHA_SIGNER = current_app.captcha_signer
STATIC_DIRECTORY = current_app.root_path / 'static'
LANG = current_app.lang
@current_app.route('/')
@with_user_from(request, fallback_to_token=True, ignore_allowedness=True)
async def home(timestamp, user_or_token):
lang, locale = get_lang_and_locale_from(request)
match user_or_token:
case str() | None as token:
failure_id = request.args.get('failure', type=int)
failure = pop_failure(failure_id)
response = await render_template(
'captcha.html',
csp=generate_csp(),
token=token,
locale=locale['anonstream']['captcha'],
digest=get_random_captcha_digest(),
failure=pop_failure(failure_id),
failure=locale['anonstream']['internal'].get(failure),
)
case dict() as user:
try:
ensure_allowedness(user, timestamp=timestamp)
except Blacklisted:
raise Forbidden('You have been blacklisted.')
raise Forbidden(locale['anonstream']['error']['blacklisted'])
except SecretClub:
# TODO allow changing tripcode
raise Forbidden('You have not been whitelisted.')
raise Forbidden(locale['anonstream']['error']['not_whitelisted'])
else:
response = await render_template(
'home.html',
csp=generate_csp(),
user=user,
lang=lang or LANG,
locale=locale['anonstream']['home'],
version=current_app.version,
)
return response
@ -54,27 +61,22 @@ async def home(timestamp, user_or_token):
@current_app.route('/stream.mp4')
@with_user_from(request)
async def stream(timestamp, user):
locale = get_locale_from(request)['anonstream']['error']
if not is_online():
raise NotFound('The stream is offline.')
raise NotFound(locale['offline'])
else:
try:
eyes_id = create_eyes(user, tuple(request.headers))
except RatelimitedEyes as e:
retry_after, *_ = e.args
error = TooManyRequests(
f'You have requested the stream recently. '
f'Try again in {retry_after:.1f} seconds.'
)
error = TooManyRequests(locale['ratelimit'] % retry_after)
response = await current_app.handle_http_exception(error)
response = await make_response(response)
response.headers['Retry-After'] = math.ceil(retry_after)
raise abort(response)
except TooManyEyes as e:
n_eyes, *_ = e.args
raise TooManyRequests(
f'You have made {n_eyes} concurrent requests for the stream. '
f'End one of those before making a new request.'
)
raise TooManyRequests(locale['limit'] % n_eyes)
else:
@with_timestamp(precise=True)
def segment_read_hook(timestamp, uri):
@ -112,6 +114,7 @@ async def captcha(timestamp, user_or_token):
@current_app.post('/access')
@with_user_from(request, fallback_to_token=True, ignore_allowedness=True)
async def access(timestamp, user_or_token):
lang = get_lang_from(request)
match user_or_token:
case str() | None as token:
form = await request.form
@ -119,16 +122,16 @@ async def access(timestamp, user_or_token):
answer = form.get('answer', '')
match check_captcha_digest(CAPTCHA_SIGNER, digest, answer):
case Answer.MISSING:
failure_id = add_failure('Captcha is required')
failure_id = add_failure('captcha_required')
case Answer.BAD:
failure_id = add_failure('Captcha was incorrect')
failure_id = add_failure('captcha_incorrect')
case Answer.EXPIRED:
failure_id = add_failure('Captcha has expired')
failure_id = add_failure('captcha_expired')
case Answer.OK:
failure_id = None
user = generate_and_add_user(timestamp, token, verified=True)
if failure_id is not None:
url = url_for('home', token=token, failure=failure_id)
url = url_for('home', token=token, lang=lang, failure=failure_id)
raise abort(redirect(url, 303))
case dict() as user:
pass

ファイルの表示

@ -1,8 +1,17 @@
from quart import current_app, render_template
from quart import current_app, render_template, request
from werkzeug.exceptions import default_exceptions
from anonstream.locale import get_locale_from
for error in default_exceptions:
async def handle(error):
return await render_template('error.html', error=error), error.code
locale = get_locale_from(request)['http']
error.description = locale.get(error.description)
return (
await render_template(
'error.html',
error=error,
locale=locale,
), error.code
)
current_app.register_error_handler(error, handle)

ファイルの表示

@ -5,6 +5,7 @@ from quart import current_app, request, render_template, redirect, url_for, esca
from anonstream.captcha import get_random_captcha_digest_for
from anonstream.chat import add_chat_message, Rejected
from anonstream.locale import get_locale_from
from anonstream.stream import is_online, get_stream_title, get_stream_uptime_and_viewership
from anonstream.user import add_state, pop_state, try_change_appearance, update_presence, get_users_by_presence, Presence, verify, deverify, BadCaptcha, reading
from anonstream.routes.wrappers import with_user_from, render_template_with_etag
@ -12,7 +13,6 @@ 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.security import generate_csp
from anonstream.utils.user import concatenate_for_notice
CONFIG = current_app.config
USERS_BY_TOKEN = current_app.users_by_token
@ -25,6 +25,7 @@ async def nojs_stream(timestamp, user):
csp=generate_csp(),
user=user,
online=is_online(),
locale=get_locale_from(request)['anonstream']['stream'],
)
@current_app.route('/info.html')
@ -37,6 +38,7 @@ async def nojs_info(timestamp, user):
{'csp': generate_csp()},
refresh=CONFIG['NOJS_REFRESH_INFO'],
user=user,
locale=get_locale_from(request)['anonstream']['info'],
viewership=viewership,
uptime=uptime,
title=await get_stream_title(),
@ -53,6 +55,7 @@ async def nojs_chat_messages(timestamp, user):
refresh=CONFIG['NOJS_REFRESH_MESSAGES'],
user=user,
users_by_token=USERS_BY_TOKEN,
locale=get_locale_from(request)['anonstream']['chat'],
messages=get_scrollback(current_app.messages),
timeout=CONFIG['NOJS_TIMEOUT_CHAT'],
get_default_name=get_default_name,
@ -73,6 +76,7 @@ async def nojs_chat_users(timestamp, user):
{'csp': generate_csp()},
refresh=CONFIG['NOJS_REFRESH_USERS'],
user=user,
locale=get_locale_from(request)['anonstream']['chat'],
get_default_name=get_default_name,
users_watching=users_by_presence[Presence.WATCHING],
users_notwatching=users_by_presence[Presence.NOTWATCHING],
@ -85,12 +89,14 @@ async def nojs_chat_form(timestamp, user):
state_id = request.args.get('state', type=int)
state = pop_state(user, state_id)
prefer_chat_form = request.args.get('landing') != 'appearance'
print(state)
return await render_template(
'nojs_chat_form.html',
csp=generate_csp(),
user=user,
state=state,
prefer_chat_form=prefer_chat_form,
state=state,
locale=get_locale_from(request)['anonstream'],
nonce=generate_nonce(),
digest=get_random_captcha_digest_for(user),
default_name=get_default_name(user),
@ -124,10 +130,10 @@ async def nojs_submit_message(timestamp, user):
try:
verification_happened = verify(user, digest, answer)
except BadCaptcha as e:
notice, *_ = e.args
string, *args = e.args
state_id = add_state(
user,
notice=notice,
notice=[(string, args)],
comment=comment[:CONFIG['CHAT_COMMENT_MAX_LENGTH']],
)
else:
@ -143,10 +149,10 @@ async def nojs_submit_message(timestamp, user):
)
message_was_added = seq is not None
except Rejected as e:
notice, *_ = e.args
string, *args = e.args
state_id = add_state(
user,
notice=notice,
notice=[(string, args)],
comment=comment[:CONFIG['CHAT_COMMENT_MAX_LENGTH']],
)
else:
@ -185,13 +191,13 @@ async def nojs_submit_appearance(timestamp, user):
# Change appearance (iff form data was good)
errors = try_change_appearance(user, name, color, password, want_tripcode)
if errors:
notice = Markup('<br>').join(
concatenate_for_notice(*error.args) for error in errors
)
notice = []
for string, *args in (error.args for error in errors):
notice.append((string, args))
else:
notice = 'Changed appearance'
notice = [('appearance_changed', ())]
state_id = add_state(user, notice=notice, verbose=len(errors) > 1)
state_id = add_state(user, notice=notice)
url = url_for(
'nojs_chat_form',
token=user['token'],

ファイルの表示

@ -12,16 +12,16 @@ const CSP = document.body.dataset.csp;
/* insert js-only markup */
const jsmarkup_stream_video = '<video id="stream__video" autoplay controls></video>'
const jsmarkup_stream_offline = '<header id="stream__offline"><h1>[offline]</h1></header>'
const jsmarkup_stream_offline = '<header id="stream__offline"><h1 data-string="offline">[offline]</h1></header>'
const jsmarkup_info = '<div id="info_js" data-js="true"></div>';
const jsmarkup_info_float = '<aside id="info_js__float"></aside>';
const jsmarkup_info_float_button = '<button id="info_js__float__button" accesskey="r">Reload stream</button>';
const jsmarkup_info_float_button = '<button id="info_js__float__button" accesskey="r" data-string="reload_stream">Reload stream</button>';
const jsmarkup_info_float_viewership = '<div id="info_js__float__viewership"></div>';
const jsmarkup_info_float_uptime = '<div id="info_js__float__uptime"></div>';
const jsmarkup_info_title = '<header id="info_js__title"></header>';
const jsmarkup_chat_messages = `\
<ol id="chat-messages_js" data-js="true"></ol>
<button id="chat-messages-unlock">Chat scroll paused. Click to resume.</button>`;
<button id="chat-messages-unlock" data-string="chat_scroll_paused">Chat scroll paused. Click to resume.</button>`;
const jsmarkup_chat_users = `\
<article id="chat-users_js">
<h5 id="chat-users_js__watching-header"></h5>
@ -37,31 +37,31 @@ const jsmarkup_chat_form = `\
<div id="chat-live">
<span id="chat-live__ball"></span>
<span id="chat-live__status">
<span data-verbose="true">Not connected to chat</span>
<span data-verbose="true" data-string="not_connected_to_chat">Not connected to chat</span>
<span data-verbose="false">&times;</span>
</span>
</div>
<input id="chat-form_js__submit" type="submit" value="Chat" accesskey="p" disabled>
<input id="chat-form_js__submit" type="submit" value="Chat" accesskey="p" disabled data-string="chat" data-string-attr="value">
<input id="chat-form_js__captcha-digest" type="hidden" name="captcha-digest" disabled>
<input id="chat-form_js__captcha-image" type="image" width="72" height="30">
<input id="chat-form_js__captcha-answer" name="captcha-answer" placeholder="Captcha" disabled>
<input id="chat-form_js__settings" type="image" src="/static/settings.svg" width="28" height="28" alt="Settings">
<input id="chat-form_js__captcha-answer" name="captcha-answer" placeholder="Captcha" disabled data-string="captcha" data-string-attr="placeholder">
<input id="chat-form_js__settings" type="image" src="/static/settings.svg" width="28" height="28" alt="Settings" data-string="settings" data-string-attr="alt">
<article id="chat-form_js__notice">
<button id="chat-form_js__notice__button" type="button">
<header id="chat-form_js__notice__button__header"></header>
<small>Click to dismiss</small>
<small data-string="click_to_dismiss">Click to dismiss</small>
</button>
</article>
</form>
<form id="appearance-form_js" data-hidden="">
<span id="appearance-form_js__label-name">Name:</span>
<span id="appearance-form_js__label-name" data-string="name">Name:</span>
<input id="appearance-form_js__name" name="name">
<input id="appearance-form_js__color" type="color" name="color">
<span id="appearance-form_js__label-tripcode">Tripcode:</span>
<input id="appearance-form_js__password" type="password" name="password" placeholder="(tripcode password)">
<span id="appearance-form_js__label-tripcode" data-string="tripcode">Tripcode:</span>
<input id="appearance-form_js__password" type="password" name="password" placeholder="(tripcode password)" data-string="tripcode_password" data-string-attr="placeholder">
<div id="appearance-form_js__row">
<article id="appearance-form_js__row__result"></article>
<input id="appearance-form_js__row__submit" type="submit" value="Update">
<input id="appearance-form_js__row__submit" type="submit" value="Update" data-string="update" data-string-attr="value">
</div>
</form>`;
@ -247,7 +247,7 @@ const create_chat_user_components = (user) => {
} else {
const chat_user_insignia = document.createElement("b");
chat_user_insignia.classList.add("chat-insignia")
chat_user_insignia.title = "Broadcaster";
chat_user_insignia.title = locale.broadcaster || "Broadcaster";
chat_user_insignia.innerText = "##";
const chat_user_insignia_nbsp = document.createElement("span");
chat_user_insignia_nbsp.innerHTML = "&nbsp;"
@ -275,6 +275,7 @@ const delete_chat_messages = (seqs) => {
}
}
let locale = {};
let users = {};
let stats = null;
let stats_received = null;
@ -438,23 +439,23 @@ const chat_form_captcha_answer = document.getElementById("chat-form_js__captcha-
chat_form_captcha_image.addEventListener("loadstart", (event) => {
chat_form_captcha_image.removeAttribute("title");
chat_form_captcha_image.removeAttribute("data-reloadable");
chat_form_captcha_image.alt = "Loading...";
chat_form_captcha_image.alt = locale.loading || "Loading...";
});
chat_form_captcha_image.addEventListener("load", (event) => {
chat_form_captcha_image.removeAttribute("alt");
chat_form_captcha_image.dataset.reloadable = "";
chat_form_captcha_image.title = "Click for a new captcha";
chat_form_captcha_image.title = locale.click_for_a_new_captcha || "Click for a new captcha";
});
chat_form_captcha_image.addEventListener("error", (event) => {
chat_form_captcha_image.alt = "Captcha failed to load";
chat_form_captcha_image.alt = locale.captcha_failed_to_load || "Captcha failed to load";
chat_form_captcha_image.dataset.reloadable = "";
chat_form_captcha_image.title = "Click for a new captcha";
chat_form_captcha_image.title = locale.click_for_a_new_captcha || "Click for a new captcha";
});
chat_form_captcha_image.addEventListener("click", (event) => {
event.preventDefault();
if (chat_form_captcha_image.dataset.reloadable !== undefined) {
chat_form_submit.disabled = true;
chat_form_captcha_image.alt = "Waiting...";
chat_form_captcha_image.alt = locale.waiting || "Waiting...";
chat_form_captcha_image.removeAttribute("title");
chat_form_captcha_image.removeAttribute("data-reloadable");
chat_form_captcha_image.removeAttribute("src");
@ -518,7 +519,7 @@ const update_uptime = () => {
setInterval(update_uptime, 1000); // always update uptime
const update_viewership = () => {
info_viewership.innerText = stats === null ? "" : `${stats.viewership} viewers`;
info_viewership.innerText = stats === null ? "" : (locale.viewers || "{0} viewers").replace('{0}', stats.viewership);
}
const update_stats = () => {
@ -564,7 +565,7 @@ const update_users_list = () => {
}
if (is_you) {
const you = document.createElement("span");
you.innerText = " (You)";
you.innerText = locale.you || " (You)";
chat_user.insertAdjacentElement("beforeend", you);
}
chat_users_sublist.insertAdjacentElement("beforeend", chat_user);
@ -584,8 +585,8 @@ const update_users_list = () => {
}
// show correct numbers
chat_users_watching_header.innerText = `Watching (${watching})`;
chat_users_notwatching_header.innerText = `Not watching (${notwatching})`;
chat_users_watching_header.innerText = (locale.watching || "Watching ({0})").replace("{0}", watching);
chat_users_notwatching_header.innerText = (locale.not_watching || "Not watching ({0})").replace("{0}", notwatching);
}
const show_offline_screen = () => {
@ -608,6 +609,21 @@ const on_websocket_message = async (event) => {
console.log("ws init", receipt);
pingpong_period = receipt.pingpong;
// update locale & put localized strings in js-inserted elements
locale = receipt.locale;
for (element of document.querySelectorAll('[data-string]')) {
const string = element.dataset.string;
if (locale[string] !== undefined) {
const attr = element.dataset.stringAttr;
if (attr === undefined)
element.innerText = locale[string];
else
element[attr] = locale[string];
}
}
// stream title
set_title(receipt.title);
// update stats (uptime/viewership)
@ -813,7 +829,7 @@ const on_websocket_message = async (event) => {
ul.insertAdjacentElement("beforeend", li);
}
const result = document.createElement("div");
result.innerText = "Errors:";
result.innerText = locale.errors || "Errors:";
result.insertAdjacentElement("beforeend", ul);
chat_appearance_form_result.innerHTML = result.innerHTML;
}
@ -850,14 +866,14 @@ const connect_websocket = () => {
return;
}
chat_live_ball.style.borderColor = "gold";
chat_live_status.innerHTML = "<span data-verbose='true'>Connecting to chat...</span><span data-verbose='false'>&middot;&middot;&middot;</span>";
chat_live_status.innerHTML = `<span data-verbose='true'>${locale.connecting_to_chat || "Connecting to chat..."}</span><span data-verbose='false'>&middot;&middot;&middot;</span>`;
ws = null;
ws = new WebSocket(`ws://${document.domain}:${location.port}/live?token=${encodeURIComponent(TOKEN)}`);
ws.addEventListener("open", (event) => {
console.log("websocket open", event);
chat_form_submit.disabled = false;
chat_live_ball.style.borderColor = "green";
chat_live_status.innerHTML = "<span><span data-verbose='true'>Connected to chat</span><span data-verbose='false'>&check;</span></span>";
chat_live_status.innerHTML = `<span><span data-verbose='true'>${locale.connected_to_chat || "Connected to chat"}</span><span data-verbose='false'>&check;</span></span>`;
// When the server is offline, a newly opened websocket can take a second
// to close. This timeout tries to ensure the backoff doesn't instantly
// (erroneously) reset to 2 seconds in that case.
@ -873,7 +889,7 @@ const connect_websocket = () => {
console.log("websocket close", event);
chat_form_submit.disabled = true;
chat_live_ball.style.borderColor = "maroon";
chat_live_status.innerHTML = "<span data-verbose='true'>Disconnected from chat</span><span data-verbose='false'>&times;</span>";
chat_live_status.innerHTML = `<span data-verbose='true'>${locale.disconnected_from_chat || "Disconnected from chat"}</span><span data-verbose='false'>&times;</span>`;
if (!ws.successor) {
ws.successor = true;
setTimeout(connect_websocket, websocket_backoff);
@ -884,7 +900,7 @@ const connect_websocket = () => {
console.log("websocket error", event);
chat_form_submit.disabled = true;
chat_live_ball.style.borderColor = "maroon";
chat_live_status.innerHTML = "<span>Error<span data-verbose='true'> connecting to chat</span></span>";
chat_live_status.innerHTML = `<span><span data-verbose='true'>${locale.error_connecting_to_chat || "Error connecting to chat"}</span><span data-verbose='false'>${locale.error_connecting_to_chat_terse || "Error"}</span></span>`;
});
ws.addEventListener("message", on_websocket_message);
}

ファイルの表示

@ -57,7 +57,7 @@
<img src="{{ url_for('captcha', digest=digest) }}" width="72" height="30">
<form action="{{ url_for('access', token=token) }}" method="post">
<input type="hidden" name="digest" value="{{ digest }}">
<input name="answer" placeholder="Captcha" required autofocus>
<input name="answer" placeholder="{{ locale.captcha }}" required autofocus>
<input type="submit" value="Submit">
{% if failure is not none %}<p>{{ failure }}</p>{% endif %}
</form>

ファイルの表示

@ -7,7 +7,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ error.code }} {{ error.name }}</title>
<title>{{ error.code }} {{ locale[error.code | string] or error.name }}</title>
<style>
body {
background-color: #232327;
@ -63,8 +63,8 @@
</head>
<body>
<main>
<h1>{{ error.name }}</h1>
{% if error.description != error.__class__.description %}
<h1>{{ locale[error.code | string] or error.name }}</h1>
{% if error.description is not none %}
<p>{{ error.description }}</p>
{% endif %}
</main>

ファイルの表示

@ -3,12 +3,12 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
##}
<!doctype html>
<html id="nochat">
<html id="nochat" lang="{{ lang }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="content-security-policy" content="default-src 'none'; connect-src 'self'; img-src 'self'; frame-src 'self'; media-src 'self'; script-src 'self'; style-src 'self' 'nonce-{{ csp }}';">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" type="text/css">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css', token=user.token) }}" type="text/css">
</head>
<body id="both" data-token="{{ user.token }}" data-token-hash="{{ user.token_hash }}" data-csp="{{ csp }}">
<article id="stream">
@ -20,15 +20,15 @@
<aside id="chat">
<input id="chat__toggle" type="checkbox">
<header id="chat__header">
<label id="chat__header__button" for="chat__toggle">Users</label>
<h3 id="chat__header__text">Stream chat</h3>
<label id="chat__header__button" for="chat__toggle">{{ locale.users }}</label>
<h3 id="chat__header__text">{{ locale.stream_chat }}</h3>
</header>
<article id="chat__body">
<section id="chat__body__messages">
<noscript><iframe id="chat-messages_nojs" src="{{ url_for('nojs_chat_messages', token=user.token, _anchor='end') }}" data-js="false"></iframe></noscript>
</section>
<section id="chat__body__users">
<header id="chat-users-header"><h4>Users in chat</h4></header>
<header id="chat-users-header"><h4>{{ locale.users_in_chat }}</h4></header>
<noscript><iframe id="chat-users_nojs" src="{{ url_for('nojs_chat_users', token=user.token) }}" data-js="false"></iframe></noscript>
</section>
</article>
@ -41,7 +41,7 @@
<a href="#chat">chat</a>
<a href="#both">both</a>
</nav>
<footer>anonstream {{ version }} &mdash; <a href="https://git.076.ne.jp/ninya9k/anonstream" target="_blank">source</a></footer>
<script src="{{ url_for('static', filename='anonstream.js') }}" type="text/javascript"></script>
<footer>anonstream {{ version }} &mdash; <a href="https://git.076.ne.jp/ninya9k/anonstream" target="_blank">{{ locale.source }}</a></footer>
<script src="{{ url_for('static', filename='anonstream.js', token=user.token) }}" type="text/javascript"></script>
</body>
</html>

ファイルの表示

@ -214,45 +214,50 @@
<input id="toggle" type="checkbox" {% if not prefer_chat_form %}checked {% endif %}accesskey="x">
{% if state.notice %}
<input id="notice-radio" type="radio" accesskey="z">
<label id="notice" for="notice-radio"{% if state.verbose %} class="verbose"{% endif %}>
<header><h2>{{ state.notice }}</h2></header>
<small>Click to dismiss</small>
<label id="notice" for="notice-radio"{% if (state.notice | length) > 1 %} class="verbose"{% endif %}>
<header><h2>
{%- for string, args in state.notice %}
{{ locale.internal[string] | escape | format(*args) -}}
{% if not loop.last %}<br>{% endif %}
{% endfor -%}
</h2></header>
<small>{{ locale.form.click_to_dismiss }}</small>
</label>
{% endif %}
<form id="chat-form" action="{{ url_for('nojs_submit_message', token=user.token) }}" method="post">
<input type="hidden" name="nonce" value="{{ nonce }}">
<textarea id="chat-form__comment" name="comment" maxlength="{{ max_comment_length }}" {% if digest is none %}required {% endif %} placeholder="Send a message..." rows="1" tabindex="1" autofocus accesskey="m">{{ state.comment }}</textarea>
<input id="chat-form__submit" type="submit" value="Chat" tabindex="4" accesskey="p">
<div id="chat-form__exit"><label for="toggle" class="pseudolink">Settings</label></div>
<textarea id="chat-form__comment" name="comment" maxlength="{{ max_comment_length }}" {% if digest is none %}required {% endif %} placeholder="{{ locale.form.send_a_message }}" rows="1" tabindex="1" autofocus accesskey="m">{{ state.comment }}</textarea>
<input id="chat-form__submit" type="submit" value="{{ locale.form.chat }}" tabindex="4" accesskey="p">
<div id="chat-form__exit"><label for="toggle" class="pseudolink">{{ locale.form.settings }}</label></div>
{% if digest %}
<input type="hidden" name="captcha-digest" value="{{ digest }}">
<input id="chat-form__captcha-image" type="image" formaction="{{ url_for('nojs_chat_form_redirect', token=user.token) }}" formnovalidate src="{{ url_for('captcha', token=user.token, digest=digest) }}" width="72" height="30" alt="Captcha failed to load" title="Click for a new captcha" tabindex="2">
<input id="chat-form__captcha-image" type="image" formaction="{{ url_for('nojs_chat_form_redirect', token=user.token) }}" formnovalidate src="{{ url_for('captcha', token=user.token, digest=digest) }}" width="72" height="30" alt="{{ locale.form.captcha_failed_to_load }}" title="{{ locale.form.click_for_a_new_captcha }}" tabindex="2">
<input id="chat-form__captcha-answer" name="captcha-answer" required placeholder="Captcha" tabindex="3">
{% endif %}
</form>
<form id="appearance-form" action="{{ url_for('nojs_submit_appearance', token=user.token) }}" method="post">
<label id="appearance-form__label-name" for="appearance-form__name">Name:</label>
<label id="appearance-form__label-name" for="appearance-form__name">{{ locale.form.name }}</label>
<input id="appearance-form__name" name="name" value="{{ user.name or '' }}" placeholder="{{ default_name }}" maxlength="{{ max_name_length }}">
<input type="color" name="color" value="{{ user.color }}">
<label id="appearance-form__label-password" for="appearance-form__password">Tripcode:</label>
<label id="appearance-form__label-password" for="appearance-form__password">{{ locale.form.tripcode }}</label>
<input id="password-toggle" name="set-tripcode" type="checkbox" accesskey="s">
<input id="cleared-toggle" name="clear-tripcode" type="checkbox"{% if user.tripcode != none %} accesskey="c"{% endif %}>
<div id="password-column">
{% if user.tripcode is none %}
<span class="tripcode">(no tripcode)</span>
<label for="password-toggle" class="show-password pseudolink">set</label>
<span class="tripcode">{{ locale.form.no_tripcode }}</span>
<label for="password-toggle" class="show-password pseudolink">{{ locale.form.set }}</label>
{% else %}
<label id="tripcode" for="password-toggle" class="show-password tripcode">{{ user.tripcode.digest }}</label>
<label id="show-cleared" for="cleared-toggle" class="pseudolink x">&times;</label>
<div id="cleared" class="tripcode">(cleared)</div>
<label id="hide-cleared" for="cleared-toggle" class="pseudolink">undo</label>
<div id="cleared" class="tripcode">{{ locale.form.cleared }}</div>
<label id="hide-cleared" for="cleared-toggle" class="pseudolink">{{ locale.form.undo }}</label>
{% endif %}
</div>
<input id="appearance-form__password" name="password" type="password" placeholder="(tripcode password)" maxlength="{{ max_password_length }}">
<input id="appearance-form__password" name="password" type="password" placeholder="{{ locale.form.tripcode_password }}" maxlength="{{ max_password_length }}">
<div id="hide-password"><label for="password-toggle" class="pseudolink x">&times;</label></div>
<div id="appearance-form__buttons">
<div id="appearance-form__buttons__exit"><label for="toggle" class="pseudolink">Return to chat</label></div>
<input type="submit" value="Update">
<div id="appearance-form__buttons__exit"><label for="toggle" class="pseudolink">{{ locale.form.return_to_chat }}</label></div>
<input type="submit" value="{{ locale.form['update'] }}">
</div>
</form>
</body>

ファイルの表示

@ -165,8 +165,8 @@
<div id="notimeout"></div>
<aside id="timeout">
<a class="button" href="{{ url_for('nojs_chat_messages_redirect', token=user.token) }}">
<header>Timed out</header>
<small>Click to refresh</small>
<header>{{ locale.timed_out }}</header>
<small>{{ locale.click_to_refresh }}</small>
</a>
</aside>
<ol id="chat-messages">
@ -183,10 +183,10 @@
{% endfor %}
</ol>
<aside id="timeout-dismiss">
<a class="button" href="#notimeout">Hide timeout notice</a>
<a class="button" href="#notimeout">{{ locale.hide_timeout_notice }}</a>
</aside>
<aside id="timeout-alt">
<a class="button" href="{{ url_for('nojs_chat_messages_redirect', token=user.token) }}">Click to refresh</a>
<a class="button" href="{{ url_for('nojs_chat_messages_redirect', token=user.token) }}">{{ locale.click_to_refresh }}</a>
</aside>
</body>
</html>

ファイルの表示

@ -109,27 +109,27 @@
<body>
<aside id="timeout">
<a href="">
<header>Timed out</header>
<small>Click to refresh</small>
<header>{{ locale.timed_out}} </header>
<small>{{ locale.click_to_refresh }}</small>
</a>
</aside>
<main id="main">
<h5>Watching ({{ users_watching | length }})</h5>
<h5>{{ locale.watching | format(users_watching | length) }}</h5>
<ul>
{% for user_listed in users_watching %}
<li class="user" data-token-hash="{{ user_listed.token_hash }}">
{{- appearance(user_listed, insignia_class='user__insignia', name_class='user__name', tag_class='user__name__tag') -}}
{%- if user.token == user_listed.token %} (You){% endif -%}
{%- if user.token == user_listed.token %}{{ locale.you }}{% endif -%}
</li>
{% endfor %}
</ul>
<br>
<h5>Not watching ({{ users_notwatching | length }})</h5>
<h5>{{ locale.not_watching | format(users_notwatching | length) }}</h5>
<ul>
{% for user_listed in users_notwatching %}
<li class="user" data-token-hash="{{ user_listed.token_hash }}">
{{- appearance(user_listed, insignia_class='user__insignia', name_class='user__name', tag_class='user__name__tag') -}}
{%- if user.token == user_listed.token %} (You){% endif -%}
{%- if user.token == user_listed.token %}{{ locale.you }}{% endif -%}
</li>
{% endfor %}
</ul>

ファイルの表示

@ -144,13 +144,13 @@
{% if user.presence != Presence.WATCHING %}
<form id="float__form" action="{{ url_for('nojs_stream') }}" target="stream_nojs">
<input type="hidden" name="token" value="{{ user.token }}">
<input type="submit" value="Reload stream" accesskey="r">
<input type="submit" value="{{ locale.reload_stream }}" accesskey="r">
</form>
{% endif %}
<div id="float__viewership">{{ viewership }} viewers</div>
<div id="float__viewership">{{ locale.viewers | format(viewership) }}</div>
<div id="float__uptime">
<div id="uptime-static"{% if uptime < 360000 %} data-hidden=""{% endif %}>
<span id="uptime-static__label">Uptime:</span>
<span id="uptime-static__label">{{ locale.uptime }}</span>
<span>
{%- if uptime >= 3600 -%}
{{- uptime | int // 3600 -}}

ファイルの表示

@ -45,7 +45,7 @@
{% if online %}
<video id="video" src="{{ url_for('stream', token=user.token) }}" autoplay controls></video>
{% else %}
<header id="offline"><h1>[offline]</h1></header>
<header id="offline"><h1>{{ locale.offline }}</h1></header>
{% endif %}
</body>
</html>

ファイルの表示

@ -6,7 +6,7 @@ import time
from functools import reduce
from math import inf
from quart import current_app
from quart import current_app, Markup
from anonstream.wrappers import try_except_log, with_timestamp, get_timestamp
from anonstream.helpers.user import get_default_name, get_presence, Presence
@ -106,10 +106,11 @@ def change_name(user, name, dry_run=False):
name = None
if name is not None:
if len(name) == 0:
raise BadAppearance('Name was empty')
raise BadAppearance('name_empty')
if len(name) > CONFIG['CHAT_NAME_MAX_LENGTH']:
raise BadAppearance(
f'Name exceeded {CONFIG["CHAT_NAME_MAX_LENGTH"]} chars'
'name_too_long',
CONFIG['CHAT_NAME_MAX_LENGTH'],
)
else:
user['name'] = name
@ -119,16 +120,13 @@ def change_color(user, color, dry_run=False):
try:
colour = color_to_colour(color)
except NotAColor:
raise BadAppearance('Invalid CSS color')
contrast = get_contrast(
CONFIG['CHAT_BACKGROUND_COLOUR'],
colour,
)
raise BadAppearance('colour_invalid_css')
contrast = get_contrast(CONFIG['CHAT_BACKGROUND_COLOUR'], colour)
min_contrast = CONFIG['CHAT_NAME_MIN_CONTRAST']
if contrast < min_contrast:
raise BadAppearance(
'Colour had insufficient contrast:',
(f'{contrast:.2f}', f'/{min_contrast:.2f}'),
'colour_insufficient_contrast',
Markup(f'<mark>{contrast:.2f}</mark>/{min_contrast:.2f}'),
)
else:
user['color'] = color
@ -137,8 +135,8 @@ def change_tripcode(user, password, dry_run=False):
if dry_run:
if len(password) > CONFIG['CHAT_TRIPCODE_PASSWORD_MAX_LENGTH']:
raise BadAppearance(
f'Password exceeded '
f'{CONFIG["CHAT_TRIPCODE_PASSWORD_MAX_LENGTH"]} chars'
'password_too_long',
CONFIG['CHAT_TRIPCODE_PASSWORD_MAX_LENGTH'],
)
else:
user['tripcode'] = generate_tripcode(password)
@ -176,11 +174,11 @@ def verify(user, digest, answer):
else:
match check_captcha_digest(CAPTCHA_SIGNER, digest, answer):
case Answer.MISSING:
raise BadCaptcha('Captcha is required')
raise BadCaptcha('captcha_required')
case Answer.BAD:
raise BadCaptcha('Captcha was incorrect')
raise BadCaptcha('captcha_incorrect')
case Answer.EXPIRED:
raise BadCaptcha('Captcha has expired')
raise BadCaptcha('captcha_expired')
case Answer.OK:
user['verified'] = True
verification_happened = True

135
anonstream/utils/locale.py ノーマルファイル
ファイルの表示

@ -0,0 +1,135 @@
import types
SPEC = {
'anonstream': {
'error': {
'blacklisted': str,
'not_whitelisted': str,
'offline': str,
'ratelimit': str,
'limit': str,
},
'internal': {
'captcha_required': str,
'captcha_incorrect': str,
'captcha_expired': str,
'message_ratelimited': str,
'message_suspected_duplicate': str,
'message_empty': str,
'message_practically_empty': str,
'message_too_long': str,
'message_too_many_lines': str,
'message_too_many_apparent_lines': str,
'appearance_changed': str,
'name_empty': str,
'name_too_long': str,
'colour_invalid_css': str,
'colour_insufficient_contrast': str,
'password_too_long': str,
},
'captcha': {
'captcha_failed_to_load': str,
'click_for_a_new_captcha': str,
},
'home': {
'source': str,
'users': str,
'users_in_chat': str,
'stream_chat': str,
},
'stream': {
'offline': str,
},
'info': {
'viewers': str,
'uptime': str,
'reload_stream': str,
},
'chat': {
'users': str,
'click_to_refresh': str,
'hide_timeout_notice': str,
'watching': str,
'not_watching': str,
'you': str,
'timed_out': str,
},
'form': {
'click_to_dismiss': str,
'send_a_message': str,
'captcha': str,
'settings': str,
'captcha_failed_to_load': str,
'click_for_a_new_captcha': str,
'chat': str,
'name': str,
'tripcode': str,
'no_tripcode': str,
'set': str,
'cleared': str,
'undo': str,
'tripcode_password': str,
'return_to_chat': str,
'update': str,
},
'js': {
'offline': str,
'reload_stream': str,
'chat_scroll_paused': str,
'not_connected': str,
'broadcaster': str,
'loading': str,
'click_for_a_new_captcha': str,
'viewers': str,
'you': str,
'watching': str,
'not_watching': str,
'errors': str,
'connecting_to_chat': str,
'connected_to_chat': str,
'disconnected_from_chat': str,
'error_connecting_to_chat': str,
'error_connecting_to_chat_terse': str,
}
},
'http': {
'400': str | None,
'401': str | None,
'403': str | None,
'404': str | None,
'405': str | None,
'410': str | None,
'500': str | None,
}
}
class Nonconforming(Exception):
pass
def _conform_to_spec(data, spec, level=()):
assert isinstance(spec, dict), \
f'bad locale spec at {level}: must be {dict}, not {type(spec)}'
if not isinstance(data, dict):
raise Nonconforming(
f'object at {level} must be dict, not {type(data)}'
)
missing_keys = set(spec.keys()) - set(data.keys())
if missing_keys:
raise Nonconforming(f'dict at {level} is missing keys {missing_keys}')
extra_keys = set(data.keys()) - set(spec.keys())
if extra_keys:
raise Nonconforming(f'dict at {level} has extra keys {extra_keys}')
for key, subspec in spec.items():
subdata = data[key]
if isinstance(subspec, dict):
_conform_to_spec(subdata, subspec, level + (key,))
else:
assert isinstance(subspec, type | types.UnionType), \
f'bad locale spec at {level + (key,)}: must be {dict | type}, not {type(subspec)}'
if not isinstance(subdata, subspec):
raise Nonconforming(
f'value at {level + (key,)} must be {subspec}, not {type(subdata)}'
)
def validate_locale(locale):
return _conform_to_spec(locale, SPEC)

ファイルの表示

@ -25,17 +25,6 @@ Presence = Enum(
def generate_token():
return secrets.token_hex(16)
def concatenate_for_notice(string, *tuples):
if not tuples:
return string
markup = Markup(
''.join(
f' <mark>{escape(x)}</mark>{escape(y)}'
for x, y in tuples
)
)
return string + markup
def trilean(presence):
match presence:
case Presence.WATCHING:

ファイルの表示

@ -9,6 +9,7 @@ from quart import current_app, websocket
from anonstream.stream import get_stream_title, get_stream_uptime_and_viewership
from anonstream.captcha import get_random_captcha_digest_for
from anonstream.chat import get_all_messages_for_websocket, add_chat_message, Rejected
from anonstream.locale import get_locale_from
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.utils.chat import generate_nonce
@ -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'],
'locale': get_locale_from(websocket)['anonstream']['js'],
})
while True:
payload = await queue.get()
@ -126,7 +128,7 @@ def handle_inbound_appearance(timestamp, queue, user, name, color, password, wan
else:
return {
'type': 'appearance',
'result': 'Changed appearance',
'result': 'Changed appearance' " [THIS STRING STILL HARDCODED]",
'name': user['name'],
'color': user['color'],
#'tripcode': user['tripcode'],

ファイルの表示

@ -1,5 +1,10 @@
secret_key = "place secret key here"
[locale]
default = "en"
offered = ["en"]
directory = "l10n/"
[socket.control]
enabled = true
address = "control.sock"

102
l10n/en.json ノーマルファイル
ファイルの表示

@ -0,0 +1,102 @@
{
"anonstream": {
"error": {
"blacklisted": "You have been blacklisted.",
"not_whitelisted": "You have not been whitelisted.",
"offline": "The stream is offline.",
"ratelimit": "You have requested the stream recently. Try again in %.1f seconds.",
"limit": "You have made %d concurrent requests or the stream. End one of those before making a new request."
},
"internal": {
"captcha_required": "Captcha required",
"captcha_incorrect": "Captcha incorrect",
"captcha_expired": "Captcha expired",
"message_ratelimited": "Chat overuse in the last %.0f seconds",
"message_suspected_duplicate": "Discarded suspected duplicate message",
"message_empty": "Message was empty",
"message_practically_empty": "Message was practically empty",
"message_too_long": "Message exceeded %d chars",
"message_too_many_lines": "Message exceeded %d lines",
"message_too_many_apparent_lines": "Message would span %d or more lines",
"appearance_changed": "Changed appearance",
"name_empty": "Name was empty",
"name_too_long": "Name exceeded %d chars",
"colour_invalid_css": "Invalid CSS color",
"colour_insufficient_contrast": "Colour had insufficient contrast: %s",
"password_too_long": "Password exceeded %d chars"
},
"captcha": {
"captcha_failed_to_load": "Captcha failed to load",
"click_for_a_new_captcha": "Click for a new captcha"
},
"home": {
"users": "Users",
"stream_chat": "Stream chat",
"users_in_chat": "Users in chat",
"source": "source"
},
"stream": {
"offline": "[offline]"
},
"info": {
"viewers": "%d viewers",
"uptime": "Uptime:",
"reload_stream": "Reload stream"
},
"chat": {
"users": "Users",
"click_to_refresh": "Click to refresh",
"hide_timeout_notice": "Hide timeout notice",
"watching": "Watching (%d)",
"not_watching": "Not watching (%d)",
"you": " (You)",
"timed_out": "Timed out"
},
"form": {
"click_to_dismiss": "Click to dismiss",
"send_a_message": "Send a message...",
"captcha": "Captcha",
"settings": "Settings",
"captcha_failed_to_load": "Captcha failed to load",
"click_for_a_new_captcha": "Click for a new captcha",
"chat": "Chat",
"name": "Name:",
"tripcode": "Tripcode:",
"no_tripcode": "(no tripcode)",
"set": "set",
"cleared": "(cleared)",
"undo": "undo",
"tripcode_password": "(tripcode password)",
"return_to_chat": "Return to chat",
"update": "Update"
},
"js": {
"offline": "[offline]",
"reload_stream": "Reload stream",
"chat_scroll_paused": "Chat scroll paused. Click to resume.",
"not_connected": "Not connected to chat",
"broadcaster": "Broadcaster",
"loading": "Loading...",
"click_for_a_new_captcha": "Click for a new captcha",
"viewers": "{0} viewers",
"you": " (You)",
"watching": "Watching ({0})",
"not_watching": "Not watching ({0})",
"errors": "Errors:",
"connecting_to_chat": "Connecting to chat...",
"connected_to_chat": "Connected to chat",
"disconnected_from_chat": "Disconnected from chat",
"error_connecting_to_chat": "Error connecting to chat",
"error_connecting_to_chat_terse": "Error"
}
},
"http": {
"400": null,
"401": null,
"403": null,
"404": null,
"405": null,
"410": null,
"500": null
}
}

13
l10n/ru.json ノーマルファイル
ファイルの表示

@ -0,0 +1,13 @@
{
"anonstream": {
},
"http": {
"400": "Плохой запрос",
"401": "Неавторизованно",
"403": "Запрещено",
"404": "Не найден",
"405": "Метод не разрешён",
"410": null,
"500": "Внутренняя ошибка сервера"
}
}