mirror of
https://github.com/kevinveenbirkenbach/homepage.veen.world.git
synced 2026-09-12 05:56:46 +00:00
fix(i18n): build catalogue paths from the language table, not the request
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>
This commit is contained in:
@@ -43,6 +43,8 @@ UI_STRINGS = (
|
|||||||
|
|
||||||
_catalogs: dict[str, dict[str, str]] = {}
|
_catalogs: dict[str, dict[str, str]] = {}
|
||||||
|
|
||||||
|
_SUPPORTED = {code: code for code in LANGUAGES}
|
||||||
|
|
||||||
|
|
||||||
def direction(code):
|
def direction(code):
|
||||||
"""Return the writing direction of ``code`` as an HTML ``dir`` value."""
|
"""Return the writing direction of ``code`` as an HTML ``dir`` value."""
|
||||||
@@ -82,13 +84,21 @@ def clear_catalogs():
|
|||||||
|
|
||||||
|
|
||||||
def catalog(code):
|
def catalog(code):
|
||||||
"""Return the merged UI and content catalogue for ``code``."""
|
"""Return the merged UI and content catalogue for ``code``.
|
||||||
if code not in _catalogs:
|
|
||||||
_catalogs[code] = {
|
The file name comes from the supported-language table, never from the
|
||||||
**read_catalog(UI_DIR / f"{code}.yaml"),
|
request value itself, so an unsupported code gets an empty catalogue
|
||||||
**read_catalog(CONTENT_DIR / f"{code}.yaml"),
|
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[code]
|
return _catalogs[known]
|
||||||
|
|
||||||
|
|
||||||
def negotiate(accepted, default=SOURCE_LANGUAGE):
|
def negotiate(accepted, default=SOURCE_LANGUAGE):
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import shutil
|
|||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
@@ -51,7 +52,7 @@ class TestDirection(unittest.TestCase):
|
|||||||
class TestTranslateTree(unittest.TestCase):
|
class TestTranslateTree(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.addCleanup(i18n._catalogs.clear)
|
self.addCleanup(i18n._catalogs.clear)
|
||||||
i18n._catalogs["xx"] = {"A card": "Eine Karte", "Pictures": "Bilder"}
|
i18n._catalogs["de"] = {"A card": "Eine Karte", "Pictures": "Bilder"}
|
||||||
|
|
||||||
def test_only_translatable_keys_are_replaced(self):
|
def test_only_translatable_keys_are_replaced(self):
|
||||||
tree = {
|
tree = {
|
||||||
@@ -65,7 +66,7 @@ class TestTranslateTree(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
translated = i18n.translate_tree(tree, "xx")
|
translated = i18n.translate_tree(tree, "de")
|
||||||
|
|
||||||
card = translated["cards"][0]
|
card = translated["cards"][0]
|
||||||
self.assertEqual(card["title"], "Bilder")
|
self.assertEqual(card["title"], "Bilder")
|
||||||
@@ -74,26 +75,26 @@ class TestTranslateTree(unittest.TestCase):
|
|||||||
self.assertEqual(card["icon"]["class"], "Pictures")
|
self.assertEqual(card["icon"]["class"], "Pictures")
|
||||||
|
|
||||||
def test_strings_inside_a_list_are_translated(self):
|
def test_strings_inside_a_list_are_translated(self):
|
||||||
translated = i18n.translate_tree({"text": ["A card", "Pictures"]}, "xx")
|
translated = i18n.translate_tree({"text": ["A card", "Pictures"]}, "de")
|
||||||
|
|
||||||
self.assertEqual(translated["text"], ["Eine Karte", "Bilder"])
|
self.assertEqual(translated["text"], ["Eine Karte", "Bilder"])
|
||||||
|
|
||||||
def test_unknown_strings_keep_their_source_value(self):
|
def test_unknown_strings_keep_their_source_value(self):
|
||||||
translated = i18n.translate_tree({"description": "Untranslated"}, "xx")
|
translated = i18n.translate_tree({"description": "Untranslated"}, "de")
|
||||||
|
|
||||||
self.assertEqual(translated["description"], "Untranslated")
|
self.assertEqual(translated["description"], "Untranslated")
|
||||||
|
|
||||||
def test_source_tree_is_left_untouched(self):
|
def test_source_tree_is_left_untouched(self):
|
||||||
tree = {"name": "Pictures"}
|
tree = {"name": "Pictures"}
|
||||||
|
|
||||||
i18n.translate_tree(tree, "xx")
|
i18n.translate_tree(tree, "de")
|
||||||
|
|
||||||
self.assertEqual(tree["name"], "Pictures")
|
self.assertEqual(tree["name"], "Pictures")
|
||||||
|
|
||||||
def test_non_string_leaves_survive(self):
|
def test_non_string_leaves_survive(self):
|
||||||
tree = {"name": 1, "text": None, "info": True}
|
tree = {"name": 1, "text": None, "info": True}
|
||||||
|
|
||||||
self.assertEqual(i18n.translate_tree(tree, "xx"), tree)
|
self.assertEqual(i18n.translate_tree(tree, "de"), tree)
|
||||||
|
|
||||||
|
|
||||||
class TestReadCatalog(unittest.TestCase):
|
class TestReadCatalog(unittest.TestCase):
|
||||||
@@ -168,6 +169,12 @@ class TestCatalogMerge(unittest.TestCase):
|
|||||||
i18n.catalog("de"), {"Close": "Zumachen", "Imprint": "Impressum"}
|
i18n.catalog("de"), {"Close": "Zumachen", "Imprint": "Impressum"}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_an_unsupported_code_never_becomes_a_path(self):
|
||||||
|
with mock.patch.object(i18n, "read_catalog") as read:
|
||||||
|
self.assertEqual(i18n.catalog("../content/de"), {})
|
||||||
|
|
||||||
|
read.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
class TestShippedCatalogs(unittest.TestCase):
|
class TestShippedCatalogs(unittest.TestCase):
|
||||||
def test_every_iso_639_1_language_is_offered(self):
|
def test_every_iso_639_1_language_is_offered(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user