diff --git a/anonstream/__init__.py b/anonstream/__init__.py index 3bedfa4..39e2082 100644 --- a/anonstream/__init__.py +++ b/anonstream/__init__.py @@ -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 diff --git a/anonstream/chat.py b/anonstream/chat.py index 667f591..2bbad1b 100644 --- a/anonstream/chat.py +++ b/anonstream/chat.py @@ -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) diff --git a/anonstream/config.py b/anonstream/config.py index e9f842f..ce4275b 100644 --- a/anonstream/config.py +++ b/anonstream/config.py @@ -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'], + } diff --git a/anonstream/locale.py b/anonstream/locale.py new file mode 100644 index 0000000..078935a --- /dev/null +++ b/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 diff --git a/anonstream/routes/core.py b/anonstream/routes/core.py index d1ea26c..6a1c36e 100644 --- a/anonstream/routes/core.py +++ b/anonstream/routes/core.py @@ -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 diff --git a/anonstream/routes/error.py b/anonstream/routes/error.py index d82a930..02f55a2 100644 --- a/anonstream/routes/error.py +++ b/anonstream/routes/error.py @@ -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) diff --git a/anonstream/routes/nojs.py b/anonstream/routes/nojs.py index e34c52e..08e4dd6 100644 --- a/anonstream/routes/nojs.py +++ b/anonstream/routes/nojs.py @@ -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('
').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'], diff --git a/anonstream/static/anonstream.js b/anonstream/static/anonstream.js index 1625afb..4498299 100644 --- a/anonstream/static/anonstream.js +++ b/anonstream/static/anonstream.js @@ -12,16 +12,16 @@ const CSP = document.body.dataset.csp; /* insert js-only markup */ const jsmarkup_stream_video = '' -const jsmarkup_stream_offline = '

[offline]

' +const jsmarkup_stream_offline = '

[offline]

' const jsmarkup_info = '
'; const jsmarkup_info_float = ''; -const jsmarkup_info_float_button = ''; +const jsmarkup_info_float_button = ''; const jsmarkup_info_float_viewership = '
'; const jsmarkup_info_float_uptime = '
'; const jsmarkup_info_title = '
'; const jsmarkup_chat_messages = `\
    -`; +`; const jsmarkup_chat_users = `\
    @@ -37,31 +37,31 @@ const jsmarkup_chat_form = `\
    - Not connected to chat + Not connected to chat ×
    - + - - + +
    - Name: + Name: - Tripcode: - + Tripcode: +
    - +
    `; @@ -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 = " " @@ -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 = "Connecting to chat...···"; + chat_live_status.innerHTML = `${locale.connecting_to_chat || "Connecting to chat..."}···`; 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 = "Connected to chat"; + chat_live_status.innerHTML = `${locale.connected_to_chat || "Connected to chat"}`; // 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 = "Disconnected from chat×"; + chat_live_status.innerHTML = `${locale.disconnected_from_chat || "Disconnected from chat"}×`; 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 = "Error connecting to chat"; + chat_live_status.innerHTML = `${locale.error_connecting_to_chat || "Error connecting to chat"}${locale.error_connecting_to_chat_terse || "Error"}`; }); ws.addEventListener("message", on_websocket_message); } diff --git a/anonstream/templates/captcha.html b/anonstream/templates/captcha.html index 3f21ee3..780601f 100644 --- a/anonstream/templates/captcha.html +++ b/anonstream/templates/captcha.html @@ -57,7 +57,7 @@
    - + {% if failure is not none %}

    {{ failure }}

    {% endif %}
    diff --git a/anonstream/templates/error.html b/anonstream/templates/error.html index ab8d9cc..404c99d 100644 --- a/anonstream/templates/error.html +++ b/anonstream/templates/error.html @@ -7,7 +7,7 @@ - {{ error.code }} {{ error.name }} + {{ error.code }} {{ locale[error.code | string] or error.name }}