diff --git a/anonstream/locale.py b/anonstream/locale.py index 078935a..a4f248e 100644 --- a/anonstream/locale.py +++ b/anonstream/locale.py @@ -2,17 +2,21 @@ from quart import current_app LOCALES = current_app.locales -def get_lang_and_locale_from(context): +def get_lang_and_locale_from(context, burrow=(), validate=True): lang = context.args.get('lang') locale = LOCALES.get(lang) if locale is None: - lang, locale = None, LOCALES[None] + if validate: + lang = None + locale = LOCALES[None] + for key in burrow: + locale = locale[key] return lang, locale -def get_lang_from(context): - lang, locale = get_lang_and_locale_from(context) +def get_lang_from(context, validate=True): + lang, locale = get_lang_and_locale_from(context, validate=validate) return lang -def get_locale_from(context): +def get_locale_from(context, burrow=()): lang, locale = get_lang_and_locale_from(context) return locale diff --git a/anonstream/routes/core.py b/anonstream/routes/core.py index 6a1c36e..e2cbaa3 100644 --- a/anonstream/routes/core.py +++ b/anonstream/routes/core.py @@ -26,7 +26,7 @@ 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) + lang, locale = get_lang_and_locale_from(request, burrow=('anonstream',)) match user_or_token: case str() | None as token: failure_id = request.args.get('failure', type=int) @@ -35,25 +35,27 @@ async def home(timestamp, user_or_token): 'captcha.html', csp=generate_csp(), token=token, - locale=locale['anonstream']['captcha'], + request_lang=get_lang_from(request, validate=False), + locale=locale['captcha'], digest=get_random_captcha_digest(), - failure=locale['anonstream']['internal'].get(failure), + failure=locale['internal'].get(failure), ) case dict() as user: try: ensure_allowedness(user, timestamp=timestamp) except Blacklisted: - raise Forbidden(locale['anonstream']['error']['blacklisted']) + raise Forbidden(locale['error']['blacklisted']) except SecretClub: # TODO allow changing tripcode - raise Forbidden(locale['anonstream']['error']['not_whitelisted']) + raise Forbidden(locale['error']['not_whitelisted']) else: response = await render_template( 'home.html', csp=generate_csp(), user=user, - lang=lang or LANG, - locale=locale['anonstream']['home'], + lang=lang, + default_lang=LANG, + locale=locale['home'], version=current_app.version, ) return response @@ -99,7 +101,7 @@ async def stream(timestamp, user): @current_app.route('/login') @auth_required async def login(): - return redirect(url_for('home'), 303) + return redirect(url_for('home', lang=get_lang_from(request)), 303) @current_app.route('/captcha.jpg') @with_user_from(request, fallback_to_token=True) @@ -114,7 +116,6 @@ 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 @@ -130,12 +131,11 @@ async def access(timestamp, user_or_token): 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, lang=lang, failure=failure_id) - raise abort(redirect(url, 303)) case dict() as user: - pass - url = url_for('home', token=user['token']) + token = user['token'] + failure_id = None + lang = get_lang_from(request, validate=failure_id is None) + url = url_for('home', token=token, lang=lang, failure=failure_id) return redirect(url, 303) @current_app.route('/static/') diff --git a/anonstream/routes/error.py b/anonstream/routes/error.py index 02f55a2..5d3ed25 100644 --- a/anonstream/routes/error.py +++ b/anonstream/routes/error.py @@ -5,13 +5,12 @@ from anonstream.locale import get_locale_from for error in default_exceptions: async def handle(error): - locale = get_locale_from(request)['http'] - error.description = locale.get(error.description) + if error.description == error.__class__.description: + error.description = None return ( await render_template( - 'error.html', - error=error, - locale=locale, + 'error.html', error=error, + locale=get_locale_from(request)['http'], ), error.code ) current_app.register_error_handler(error, handle) diff --git a/anonstream/routes/nojs.py b/anonstream/routes/nojs.py index 08e4dd6..17fbc6b 100644 --- a/anonstream/routes/nojs.py +++ b/anonstream/routes/nojs.py @@ -5,7 +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.locale import get_lang_and_locale_from, get_lang_from, 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 @@ -64,7 +64,12 @@ async def nojs_chat_messages(timestamp, user): @current_app.route('/chat/messages') @with_user_from(request) async def nojs_chat_messages_redirect(timestamp, user): - url = url_for('nojs_chat_messages', token=user['token'], _anchor='end') + url = url_for( + 'nojs_chat_messages', + token=user['token'], + lang=get_lang_from(request), + _anchor='end', + ) return redirect(url, 303) @current_app.route('/chat/users.html') @@ -86,17 +91,18 @@ async def nojs_chat_users(timestamp, user): @current_app.route('/chat/form.html') @with_user_from(request) async def nojs_chat_form(timestamp, user): + lang, locale = get_lang_and_locale_from(request) 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, prefer_chat_form=prefer_chat_form, state=state, - locale=get_locale_from(request)['anonstream'], + lang=lang, + locale=locale['anonstream'], nonce=generate_nonce(), digest=get_random_captcha_digest_for(user), default_name=get_default_name(user), @@ -116,7 +122,12 @@ async def nojs_chat_form_redirect(timestamp, user): ) else: state_id = None - url = url_for('nojs_chat_form', token=user['token'], state=state_id) + url = url_for( + 'nojs_chat_form', + token=user['token'], + lang=get_lang_from(request), + state=state_id, + ) return redirect(url, 303) @current_app.post('/chat/message') @@ -163,6 +174,7 @@ async def nojs_submit_message(timestamp, user): url = url_for( 'nojs_chat_form', token=user['token'], + lang=get_lang_from(request), landing='chat', state=state_id, ) @@ -201,6 +213,7 @@ async def nojs_submit_appearance(timestamp, user): url = url_for( 'nojs_chat_form', token=user['token'], + lang=get_lang_from(request), landing='appearance' if errors else 'chat', state=state_id, ) diff --git a/anonstream/routes/wrappers.py b/anonstream/routes/wrappers.py index fcc1a77..8ed6e55 100644 --- a/anonstream/routes/wrappers.py +++ b/anonstream/routes/wrappers.py @@ -8,11 +8,12 @@ import string from functools import wraps from urllib.parse import quote, unquote -from quart import current_app, request, make_response, render_template, request, url_for, Markup +from quart import current_app, request, make_response, render_template, request, url_for, escape, Markup from werkzeug.exceptions import BadRequest, Unauthorized, Forbidden from werkzeug.security import check_password_hash from anonstream.broadcast import broadcast +from anonstream.locale import get_lang_and_locale_from, get_locale_from from anonstream.user import ensure_allowedness, Blacklisted, SecretClub from anonstream.helpers.user import generate_user from anonstream.utils.user import generate_token, Presence @@ -53,18 +54,11 @@ def auth_required(f): async def wrapper(*args, **kwargs): if check_auth(request): return await f(*args, **kwargs) - hint = ( - 'The broadcaster should log in with the credentials printed in ' - 'their terminal.' - ) + locale = get_locale_from(request)['anonstream']['error'] if request.authorization is None: - description = hint + description = locale['broadcaster_should_log_in'] else: - description = Markup( - f'Wrong username or password. Refresh the page to try again. ' - f'
' - f'{hint}' - ) + description = locale['wrong_username_or_password'] error = Unauthorized(description) response = await current_app.handle_http_exception(error) response = await make_response(response) @@ -107,11 +101,11 @@ def with_user_from(context, fallback_to_token=False, ignore_allowedness=False): # Reject invalid tokens if isinstance(token, str) and not RE_TOKEN.fullmatch(token): - raise BadRequest(Markup( - f'Your token contains disallowed characters or is too ' - f'long. Tokens must match this regular expression:
' - f'{RE_TOKEN.pattern}' - )) + locale = get_locale_from(context) + args = ( + Markup(f'
{RE_TOKEN.pattern}'), + ) + raise BadRequest(escape(locale['invalid_token']) % args) # Only logged in broadcaster may have the broadcaster's token if ( @@ -119,15 +113,16 @@ def with_user_from(context, fallback_to_token=False, ignore_allowedness=False): and isinstance(token, str) and hmac.compare_digest(token, CONFIG['AUTH_TOKEN']) ): - raise Unauthorized(Markup( - f"You are using the broadcaster's token but you are " - f"not logged in. The broadcaster should " - f"" - f"click here" - f" " - f"and log in with the credentials printed in their " - f"terminal when they started anonstream." - )) + lang, locale = get_lang_and_locale_from( + context, burrow=('anonstream', 'error'), + ) + args = ( + Markup(f''''''), + Markup(f''''''), + ) + raise Unauthorized( + escape(locale['impostor']) % args + ) # Create response user = USERS_BY_TOKEN.get(token) @@ -136,19 +131,25 @@ def with_user_from(context, fallback_to_token=False, ignore_allowedness=False): user['last']['seen'] = timestamp user['headers'] = tuple(context.headers) if not ignore_allowedness: - assert_allowedness(timestamp, user) + assert_allowedness(context, 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 response = await f(timestamp, token, *args, **kwargs) else: - raise Forbidden(Markup( - f"You have not solved the access captcha. " - f"" - f"Click here." - f"" - )) + lang, locale = get_lang_and_locale_from( + context, burrow=('anonstream', 'error'), + ) + args = ( + Markup(f''''''), + Markup(f''''''), + ) + if user is None: + string = locale['captcha'] + else: + string = locale['captcha_again'] + raise Forbidden(escape(string) % args) else: if user is not None: user['last']['seen'] = timestamp @@ -161,7 +162,7 @@ def with_user_from(context, fallback_to_token=False, ignore_allowedness=False): headers=tuple(context.headers), ) if not ignore_allowedness: - assert_allowedness(timestamp, user) + assert_allowedness(context, timestamp, user) response = await f(timestamp, user, *args, **kwargs) # Set cookie @@ -229,10 +230,12 @@ def etag_conditional(f): return wrapper -def assert_allowedness(timestamp, user): +def assert_allowedness(context, timestamp, user): try: ensure_allowedness(user, timestamp=timestamp) except Blacklisted as e: - raise Forbidden('You have been blacklisted.') + locale = get_locale_from(context)['anonstream']['error'] + raise Forbidden(locale['blacklisted']) except SecretClub as e: - raise Forbidden('You have not been whitelisted.') + locale = get_locale_from(context)['anonstream']['error'] + raise Forbidden(locale['whitelisted']) diff --git a/anonstream/static/anonstream.js b/anonstream/static/anonstream.js index 4498299..6d27988 100644 --- a/anonstream/static/anonstream.js +++ b/anonstream/static/anonstream.js @@ -7,6 +7,9 @@ const TOKEN = document.body.dataset.token; const TOKEN_HASH = document.body.dataset.tokenHash; +/* language */ +const LANG = document.firstElementChild.lang; + /* Content Security Policy nonce */ const CSP = document.body.dataset.csp; @@ -868,7 +871,7 @@ const connect_websocket = () => { chat_live_ball.style.borderColor = "gold"; 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 = new WebSocket(`ws://${document.domain}:${location.port}/live?token=${encodeURIComponent(TOKEN)}&lang=${encodeURIComponent(LANG)}`); ws.addEventListener("open", (event) => { console.log("websocket open", event); chat_form_submit.disabled = false; diff --git a/anonstream/templates/captcha.html b/anonstream/templates/captcha.html index 780601f..0be0e33 100644 --- a/anonstream/templates/captcha.html +++ b/anonstream/templates/captcha.html @@ -55,7 +55,7 @@ -
+ diff --git a/anonstream/templates/home.html b/anonstream/templates/home.html index 5454a3f..93bc3e0 100644 --- a/anonstream/templates/home.html +++ b/anonstream/templates/home.html @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-or-later ##} - + @@ -15,7 +15,7 @@
- +
diff --git a/anonstream/templates/nojs_chat_form.html b/anonstream/templates/nojs_chat_form.html index afb3449..b28344f 100644 --- a/anonstream/templates/nojs_chat_form.html +++ b/anonstream/templates/nojs_chat_form.html @@ -136,7 +136,7 @@ #appearance-form__buttons { grid-column: 1 / span 3; display: grid; - grid-template-columns: auto 5rem; + grid-template-columns: auto 6rem; } #password-column { display: grid; @@ -224,7 +224,7 @@ {{ locale.form.click_to_dismiss }} {% endif %} - + @@ -235,7 +235,7 @@ {% endif %}
-
+ diff --git a/anonstream/utils/locale.py b/anonstream/utils/locale.py index c0e8edd..7ffad87 100644 --- a/anonstream/utils/locale.py +++ b/anonstream/utils/locale.py @@ -3,6 +3,12 @@ import types SPEC = { 'anonstream': { 'error': { + 'invalid_token': str, + 'captcha': str, + 'captcha_again': str, + 'impostor': str, + 'broadcaster_should_log_in': str, + 'wrong_username_or_password': str, 'blacklisted': str, 'not_whitelisted': str, 'offline': str, @@ -32,6 +38,9 @@ SPEC = { 'click_for_a_new_captcha': str, }, 'home': { + 'info': str, + 'chat': str, + 'both': str, 'source': str, 'users': str, 'users_in_chat': str, diff --git a/config.toml b/config.toml index f0afa0e..deed459 100644 --- a/config.toml +++ b/config.toml @@ -2,7 +2,7 @@ secret_key = "place secret key here" [locale] default = "en" -offered = ["en"] +offered = ["en", "de"] directory = "l10n/" [socket.control] @@ -31,6 +31,7 @@ file_cache_lifetime = 0.5 [access] captcha = true +hide_offered_locales = 0 #"don't" "from-new" "from-everyone" [captcha] lifetime = 1800 diff --git a/l10n/de.json b/l10n/de.json new file mode 100644 index 0000000..e6df724 --- /dev/null +++ b/l10n/de.json @@ -0,0 +1,111 @@ +{ + "anonstream": { + "error": { + "invalid_token": "invalid_token%s", + "captcha": "captcha%s%s", + "captcha_again": "captcha_again%s%s", + "impostor": "impostor%s%s", + "broadcaster_should_log_in": "broadcaster_should_log_in", + "wrong_username_or_password": "wrong_username_or_password%s", + "blacklisted": "Du wurdest ge-blacklist-et.", + "not_whitelisted": "Du wurdest nicht ge-whitelist-et.", + "offline": "Der Stream ist offline.", + "ratelimit": "Du hast den Stream bereits vor kurzem angefragt. Versuche es erneut in %.1f Sekunden.", + "limit": "Du hast eine Stream-Verbindung %d mal gleichzeitig angefragt. Beende eine Verbindung bevor du eine neue Anfrage versuchst." + }, + "internal": { + "captcha_required": "Captcha benötigt", + "captcha_incorrect": "Falsches Captcha", + "captcha_expired": "Captcha abgelaufen", + "message_ratelimited": "Chat-Überlastung in den letzten %.0f Sekunden", + "message_suspected_duplicate": "Verworfen, Duplikat vermutet", + "message_empty": "Die Nachricht war leer", + "message_practically_empty": "Die Nachricht war praktisch leer", + "message_too_long": "Nachricht hat %d Zeichen überschritten", + "message_too_many_lines": "Nachricht hat %d Zeilen überschritten", + "message_too_many_apparent_lines": "Nachricht würde %d oder mehr Zeilen umfassen", + "appearance_changed": "Aussehen geändert", + "name_empty": "Namensfeld war leer", + "name_too_long": "Name überschreitet %d Zeichen", + "colour_invalid_css": "Ungültige CSS-Farbe", + "colour_insufficient_contrast": "Farbe hat nicht ausreichend Kontrast: %s", + "password_too_long": "Passwort überschreitet %d Zeichen" + }, + "captcha": { + "captcha_failed_to_load": "Captcha konnte nicht geladen werden", + "click_for_a_new_captcha": "Klick für ein neues Captcha" + }, + "home": { + "info": "Info", + "chat": "Chat", + "both": "Beide", + "users": "Benutzer", + "stream_chat": "Stream-Chat", + "users_in_chat": "Benutzer im Chat", + "source": "Quelltext" + }, + "stream": { + "offline": "[offline]" + }, + "info": { + "viewers": "%d Zuschauer", + "uptime": "Zeit:", + "reload_stream": "Stream neu laden" + }, + "chat": { + "users": "Benutzer", + "click_to_refresh": "Klicken zum aktualisieren", + "hide_timeout_notice": "Auszeitnachricht ausblenden", + "watching": "Zuschauer (%d)", + "not_watching": "Inaktive Zuschauer (%d)", + "you": " (Du)", + "timed_out": "Zeitüberschreitung" + }, + "form": { + "click_to_dismiss": "Klicken zum Ausblenden", + "send_a_message": "Schreib eine Nachricht...", + "captcha": "Captcha", + "settings": "Einstellungen", + "captcha_failed_to_load": "Captcha konnte nicht geladen werden", + "click_for_a_new_captcha": "Klick für ein neues Captcha", + "chat": "Chat", + "name": "Name:", + "tripcode": "Tripcode:", + "no_tripcode": "(kein Tripcode)", + "set": "setzen", + "cleared": "(geleert)", + "undo": "zurück", + "tripcode_password": "(Tripcode-Passwort)", + "return_to_chat": "Zurück zum Chat", + "update": "Aktualisieren" + }, + "js": { + "offline": "[offline]", + "reload_stream": "Stream neuladen", + "chat_scroll_paused": "Chat-Rollen pausiert. Klick zum Wiederaufnehmen.", + "not_connected": "Nicht mit dem Chat verbunden", + "broadcaster": "Sender", + "loading": "Läd...", + "click_for_a_new_captcha": "Klick für ein neues Captcha", + "viewers": "{0} Zuschauer", + "you": " (Du)", + "watching": "Zuschauer ({0})", + "not_watching": "Inaktive Zuschauer ({0})", + "errors": "Fehler:", + "connecting_to_chat": "Baue Verbindung zum Chat auf...", + "connected_to_chat": "Verbunden mit dem Chat", + "disconnected_from_chat": "Verbindung zum Chat getrennt", + "error_connecting_to_chat": "Fehler bei der Verbindung zum Chat", + "error_connecting_to_chat_terse": "Fehler" + } + }, + "http": { + "400": null, + "401": null, + "403": null, + "404": null, + "405": null, + "410": null, + "500": null + } +} diff --git a/l10n/en.json b/l10n/en.json index ec71a22..0918e8f 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -1,11 +1,17 @@ { "anonstream": { "error": { + "invalid_token": "Your token contains disallowed characters or is too long. Tokens must match this regular expression: %s", + "captcha": "You have not solved the access captcha. %sClick here.%s", + "captcha_again": "You must solve the access captcha again because you have been away. %sClick here.%s", + "impostor": "You are using the broadcaster's token but you are not logged in. The broadcaster should %sclick here%s and log in with the credentials printed in their terminal when they started anonstream.", + "broadcaster_should_log_in": "The broadcaster should log in with the credentials printed in their terminal.", + "wrong_username_or_password": "Wrong username or password. Refresh the page to try again. %sThe broadcaster should log in with the credentials printed in their terminal.", "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." + "ratelimit": "You have requested the stream recently. Try again in %.1f seconds.", + "limit": "You have made %d concurrent requests for the stream. End one of those before making a new request." }, "internal": { "captcha_required": "Captcha required", @@ -30,6 +36,9 @@ "click_for_a_new_captcha": "Click for a new captcha" }, "home": { + "info": "info", + "chat": "chat", + "both": "both", "users": "Users", "stream_chat": "Stream chat", "users_in_chat": "Users in chat", @@ -91,12 +100,12 @@ } }, "http": { - "400": null, - "401": null, - "403": null, - "404": null, - "405": null, - "410": null, - "500": null + "400": "Bad Request", + "401": "Unauthorized", + "403": "Forbidden", + "404": "Not Found", + "405": "Method Not Allowed", + "410": "Gone", + "500": "Internal Server Error" } }