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]
[offline]