mirror of
https://github.com/kevinveenbirkenbach/homepage.veen.world.git
synced 2026-09-10 21:26:48 +00:00
catalog() joined the requested language code straight into the UI and content catalogue paths, and read_catalog logged those paths. Both the negotiated Accept-Language code and the /<lang>/ route only ever pass supported codes, but that guarantee lived in the callers, so CodeQL reported path injection and log injection on the request value. catalog() now resolves the code through a table of the supported languages and builds the file names from the table's value, so an unsupported code returns an empty catalogue and never becomes a path. A unit test holds "../content/de" to that without reading any file, and the translate_tree fixture moves from the made-up code "xx" to "de" because unsupported codes now translate to English by design. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
145 lines
4.5 KiB
Python
145 lines
4.5 KiB
Python
"""Language negotiation and translation of the resolved configuration tree.
|
|
|
|
Translation is catalogue-driven: a string is replaced only when the target
|
|
language's catalogue holds an entry for the exact English source string.
|
|
Anything unknown falls through to English, so a partially filled catalogue
|
|
degrades instead of breaking.
|
|
"""
|
|
|
|
import logging
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
|
|
try:
|
|
from app.utils.languages import LANGUAGES, RTL_LANGUAGES
|
|
except ImportError: # pragma: no cover - supports running from the app/ directory.
|
|
from utils.languages import LANGUAGES, RTL_LANGUAGES
|
|
|
|
I18N_DIR = Path(__file__).resolve().parent.parent / "i18n"
|
|
UI_DIR = I18N_DIR / "ui"
|
|
CONTENT_DIR = I18N_DIR / "content"
|
|
|
|
SOURCE_LANGUAGE = "en"
|
|
|
|
|
|
TRANSLATABLE_KEYS = frozenset(
|
|
{"description", "info", "name", "subtitel", "text", "title", "warning"}
|
|
)
|
|
|
|
UI_STRINGS = (
|
|
"Alternatives",
|
|
"Close",
|
|
"Copy",
|
|
"Identifier copied to clipboard!",
|
|
"Imprint",
|
|
"Information",
|
|
"Language",
|
|
"Open",
|
|
"Open Link",
|
|
"Options",
|
|
"Warning",
|
|
)
|
|
|
|
_catalogs: dict[str, dict[str, str]] = {}
|
|
|
|
_SUPPORTED = {code: code for code in LANGUAGES}
|
|
|
|
|
|
def direction(code):
|
|
"""Return the writing direction of ``code`` as an HTML ``dir`` value."""
|
|
return "rtl" if code in RTL_LANGUAGES else "ltr"
|
|
|
|
|
|
def read_catalog(path):
|
|
"""Return the catalogue at ``path``, or an empty one if it is unusable.
|
|
|
|
Catalogues are hand-edited and machine-written, so a stray character must
|
|
degrade that language to English rather than take every page down with a
|
|
parse error. Non-string entries are dropped for the same reason: they would
|
|
otherwise reach the templates and render as ``42`` or ``null``.
|
|
"""
|
|
if not path.exists():
|
|
return {}
|
|
try:
|
|
loaded = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
except (OSError, UnicodeDecodeError, yaml.YAMLError):
|
|
logging.warning("Ignoring unreadable translation catalogue: %s", path)
|
|
return {}
|
|
if not isinstance(loaded, dict):
|
|
logging.warning(
|
|
"Ignoring translation catalogue that is not a mapping: %s", path
|
|
)
|
|
return {}
|
|
return {
|
|
key: value
|
|
for key, value in loaded.items()
|
|
if isinstance(key, str) and isinstance(value, str)
|
|
}
|
|
|
|
|
|
def clear_catalogs():
|
|
"""Drop the memoized catalogues so edited files are picked up."""
|
|
_catalogs.clear()
|
|
|
|
|
|
def catalog(code):
|
|
"""Return the merged UI and content catalogue for ``code``.
|
|
|
|
The file name comes from the supported-language table, never from the
|
|
request value itself, so an unsupported code gets an empty catalogue
|
|
instead of a path.
|
|
"""
|
|
known = _SUPPORTED.get(code)
|
|
if known is None:
|
|
return {}
|
|
if known not in _catalogs:
|
|
_catalogs[known] = {
|
|
**read_catalog(UI_DIR / f"{known}.yaml"),
|
|
**read_catalog(CONTENT_DIR / f"{known}.yaml"),
|
|
}
|
|
return _catalogs[known]
|
|
|
|
|
|
def negotiate(accepted, default=SOURCE_LANGUAGE):
|
|
"""Pick the best supported language from ``Accept-Language`` pairs.
|
|
|
|
Args:
|
|
accepted: iterable of ``(tag, quality)`` as produced by
|
|
``flask.request.accept_languages``.
|
|
default: language returned when no tag is supported.
|
|
|
|
Werkzeug's own ``best_match`` returns an exact match before it considers
|
|
primary-tag fallbacks, so ``de-DE,en;q=0.8`` resolves to English. Matching
|
|
on the primary subtag up front avoids that.
|
|
"""
|
|
best, best_quality = default, 0.0
|
|
for tag, quality in accepted:
|
|
code = tag.replace("_", "-").split("-")[0].lower()
|
|
if code in LANGUAGES and quality > best_quality:
|
|
best, best_quality = code, quality
|
|
return best
|
|
|
|
|
|
def translate_tree(node, code, key=None):
|
|
"""Return a copy of ``node`` with translatable leaves swapped for ``code``.
|
|
|
|
Args:
|
|
node: the resolved configuration tree, or any subtree of it.
|
|
code: target language code.
|
|
key: the mapping key ``node`` was reached through.
|
|
"""
|
|
if isinstance(node, dict):
|
|
return {name: translate_tree(value, code, name) for name, value in node.items()}
|
|
if isinstance(node, list):
|
|
return [translate_tree(item, code, key) for item in node]
|
|
if isinstance(node, str) and key in TRANSLATABLE_KEYS:
|
|
return catalog(code).get(node, node)
|
|
return node
|
|
|
|
|
|
def ui_strings(code):
|
|
"""Return the interface strings for ``code``, keyed by their English source."""
|
|
entries = catalog(code)
|
|
return {source: entries.get(source, source) for source in UI_STRINGS}
|