diff --git a/superset-frontend/src/preamble.ts b/superset-frontend/src/preamble.ts index e9b752f09fe..d4b11e5f9b7 100644 --- a/superset-frontend/src/preamble.ts +++ b/superset-frontend/src/preamble.ts @@ -65,12 +65,14 @@ export default function initPreamble(): Promise { setupClient({ appRoot: applicationRoot() }); // Load language pack before rendering. - // Prefer the bootstrap-injected pack (stashed on window by the inline - // script in spa.html, sourced from common.language_pack) so - // module-level `const X = t('...')` calls in code-split chunks all - // see a configured translator. Fall back to the async fetch only - // when the bootstrap payload didn't carry the pack (e.g. embedded - // or a legacy entry that doesn't extend spa.html). See issue #35330. + // Prefer the pack already on hand: either an operator-supplied + // common.language_pack (COMMON_BOOTSTRAP_OVERRIDES_FUNC) or the + // window global set by the versioned script tag spa.html loads + // before this bundle. Either way, module-level `const X = t('...')` + // calls in code-split chunks all see a configured translator. Fall + // back to the async fetch only when neither is present (e.g. the + // script tag failed to load, or a legacy entry that doesn't extend + // spa.html). See issue #35330. if (lang !== 'en') { const bootstrapPack = (bootstrapData.common as { language_pack?: LanguagePack }) diff --git a/superset/embedded/view.py b/superset/embedded/view.py index 84300d3c3d0..e4512fa75b6 100644 --- a/superset/embedded/view.py +++ b/superset/embedded/view.py @@ -25,7 +25,11 @@ from superset import event_logger, is_feature_enabled from superset.daos.dashboard import EmbeddedDashboardDAO from superset.superset_typing import FlaskResponse from superset.utils import json -from superset.views.base import BaseSupersetView, common_bootstrap_payload +from superset.views.base import ( + BaseSupersetView, + common_bootstrap_payload, + get_language_pack_template_context, +) class EmbeddedView(BaseSupersetView): @@ -110,4 +114,5 @@ class EmbeddedView(BaseSupersetView): bootstrap_data=json.dumps( bootstrap_data, default=json.pessimistic_json_iso_dttm_ser ), + **get_language_pack_template_context(), ) diff --git a/superset/templates/superset/spa.html b/superset/templates/superset/spa.html index 89d33f26606..07d80a6e56a 100644 --- a/superset/templates/superset/spa.html +++ b/superset/templates/superset/spa.html @@ -150,35 +150,41 @@ {% block tail_js %} {# - Expose the language pack on window BEFORE the entry bundle loads, - so module-level `const X = t('...')` calls across webpack-split - chunks all find a configured translator on first access. The - bootstrap div is the single source of truth; we parse it once and - stash the pack on a global that the translator reads lazily. - See upstream issue #35330. - - Trade-off: this ships the full Jed pack inline in every full-page - HTML response (only for non-English locales; English injects null) - rather than via the separately-cacheable `/language_pack/` fetch. - That synchronous availability is the whole point — the async fetch - can't guarantee the pack is configured before a code-split chunk - evaluates. Acceptable for an SPA where hard reloads are rare. + Load the language pack BEFORE the entry bundle so module-level + `const X = t('...')` calls across webpack-split chunks all find a + configured translator on first access (upstream issue #35330). The + script sets window.__SUPERSET_LANGUAGE_PACK__ and is content-addressed + (/language_pack///script.js), so browsers cache it as + immutable across page loads and cache-bust automatically whenever + translations change. English emits no tag at all. If the script fails + to load, preamble.ts falls back to the async language pack fetch. #} - + {% endif %} + {# + Operators can still supply a pack directly in the bootstrap payload + via COMMON_BOOTSTRAP_OVERRIDES_FUNC (the historical #35330 + workaround). When one is present, stash it on window here instead of + emitting the versioned script tag, so early chunks see the override. + #} + {% if language_pack_inline %} + + })(); + + {% endif %} {% if entry %} {{ js_bundle(assets_prefix, entry) }} {% endif %} diff --git a/superset/translations/utils.py b/superset/translations/utils.py index 36be0331a4d..79bbe872604 100644 --- a/superset/translations/utils.py +++ b/superset/translations/utils.py @@ -14,6 +14,7 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. +import hashlib import json import logging import os @@ -24,9 +25,41 @@ logger = logging.getLogger(__name__) # Global caching for JSON language packs ALL_LANGUAGE_PACKS: dict[str, dict[str, Any]] = {"en": {}} +# Global caching for language pack content hashes, used to build +# content-addressed (cache-busting) asset URLs +ALL_LANGUAGE_PACK_VERSIONS: dict[str, str] = {} + DIR = os.path.dirname(os.path.abspath(__file__)) +def get_language_pack_filename(locale: str) -> str: + """Resolve the on-disk JSON pack for a locale (empty pack for English)""" + if not locale or locale == "en": + # Forcing a dummy, quasi-empty language pack for English since the file + # in the en directory contains data with empty mappings + return DIR + "/empty_language_pack.json" + return DIR + f"/{locale}/LC_MESSAGES/messages.json" + + +def get_language_pack_version(locale: str) -> Optional[str]: + """Get/cache a short content hash of the language pack file + + The hash is embedded in the pack's asset URL so browsers can cache it + as immutable and pick up a fresh copy whenever translations change. + Returns None when the pack file cannot be read. + """ + version = ALL_LANGUAGE_PACK_VERSIONS.get(locale) + if not version: + try: + with open(get_language_pack_filename(locale), "rb") as f: + version = hashlib.sha256(f.read()).hexdigest()[:12] + ALL_LANGUAGE_PACK_VERSIONS[locale] = version + except OSError: + logger.warning("No language pack file to version for locale %s", locale) + return None + return version + + def get_language_pack(locale: str) -> Optional[dict[str, Any]]: """Get/cache a language pack @@ -37,11 +70,7 @@ def get_language_pack(locale: str) -> Optional[dict[str, Any]]: """ pack = ALL_LANGUAGE_PACKS.get(locale) if not pack: - filename = DIR + f"/{locale}/LC_MESSAGES/messages.json" - if not locale or locale == "en": - # Forcing a dummy, quasy-empty language pack for English since the file - # in the en directory is contains data with empty mappings - filename = DIR + "/empty_language_pack.json" + filename = get_language_pack_filename(locale) try: with open(filename, encoding="utf8") as f: pack = json.load(f) diff --git a/superset/views/base.py b/superset/views/base.py index 7bd76ef5824..3affcb03328 100644 --- a/superset/views/base.py +++ b/superset/views/base.py @@ -65,7 +65,7 @@ from superset.themes.types import Theme, ThemeMode from superset.themes.utils import ( is_valid_theme, ) -from superset.translations.utils import get_language_pack +from superset.translations.utils import get_language_pack_version from superset.utils import core as utils, json from superset.utils.filters import get_dataset_access_filters from superset.utils.version import get_version_metadata, visible_version_metadata @@ -578,25 +578,47 @@ def common_bootstrap_payload() -> dict[str, Any]: # Convert locale to string for proper cache key hashing locale_str = str(locale) if locale else None payload = dict(cached_common_bootstrap_data(utils.get_user_id(), locale_str)) - # Inject the Jed language pack outside the per-user memoize so the cached - # payload stays small and the pack is shared across users for the same - # locale. The frontend uses it to configure the translator synchronously, - # before any code-split chunk evaluates a module-level `const X = t('...')` - # (upstream issue #35330). - language = payload.get("locale") - if language and language != "en": - # Respect a pack already provided via COMMON_BOOTSTRAP_OVERRIDES_FUNC - # (the workaround in #35330 does exactly that), otherwise load the - # shared one. `get_language_pack` returns the empty English pack on a - # miss, which is the right result (English) when no translation file - # exists. - pack = payload.get("language_pack") or get_language_pack(language) - else: - pack = None - payload["language_pack"] = pack + # The language pack itself is NOT embedded in the payload: spa.html loads + # it through the content-addressed /language_pack///script.js + # tag before the entry bundle, keeping HTML small while still configuring + # the translator synchronously (upstream issue #35330). A pack provided via + # COMMON_BOOTSTRAP_OVERRIDES_FUNC (the historical workaround) is respected + # and takes precedence over the script tag. + payload.setdefault("language_pack", None) return payload +def get_language_pack_template_context() -> dict[str, Any]: + """Template vars controlling how spa.html delivers the language pack. + + Three mutually exclusive outcomes: + - English (or no locale): no script tag, nothing to stash. + - Operator supplied a pack via COMMON_BOOTSTRAP_OVERRIDES_FUNC: no script + tag; spa.html stashes the bootstrap pack on window instead. + - Otherwise: emit the content-addressed script URL so the browser can + cache the pack as immutable and cache-bust on translation changes. + """ + payload = common_bootstrap_payload() + language = payload.get("locale") + if not language or language == "en": + return {"language_pack_src": None, "language_pack_inline": False} + if payload.get("language_pack"): + return {"language_pack_src": None, "language_pack_inline": True} + version = get_language_pack_version(language) + return { + "language_pack_src": ( + url_for( + "Superset.language_pack_script", + lang=language, + version=version, + ) + if version + else None + ), + "language_pack_inline": False, + } + + def get_spa_payload(extra_data: dict[str, Any] | None = None) -> dict[str, Any]: """Generate standardized payload for spa.html template rendering. @@ -695,6 +717,7 @@ def get_spa_template_context( "dark_theme_bg": dark_theme_bg, "spinner_svg": spinner_svg, "default_title": default_title, + **get_language_pack_template_context(), **template_kwargs, } diff --git a/superset/views/core.py b/superset/views/core.py index 75bf6d3e48c..e9f18d3a7f5 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -82,6 +82,7 @@ from superset.superset_typing import ( FlaskResponse, ) from superset.tasks.utils import get_current_user +from superset.translations.utils import get_language_pack, get_language_pack_version from superset.utils import core as utils, json from superset.utils.cache import etag_cache from superset.utils.core import ( @@ -917,6 +918,48 @@ class Superset(BaseSupersetView): "Language pack doesn't exist on the server", status=404 ) + @expose("/language_pack///script.js") + def language_pack_script(self, lang: str, version: str) -> FlaskResponse: + """Serve the language pack as a content-addressed classic script. + + spa.html loads this BEFORE the entry bundle so translations are + configured synchronously (no race with code-split chunks), while the + versioned URL lets browsers cache the pack as immutable and pick up a + fresh copy whenever translations change. + + Deliberately unauthenticated: translation catalogs are static, public + content shipped in the Superset repo, contain no user or tenant data, + and must load for anonymous principals (login page, embedded). + """ + # Only allow expected language formats like "en", "pt_BR", etc. + if not re.match(r"^[a-z]{2,3}(_[A-Z]{2})?$", lang): + abort(400, "Invalid language code") + if not re.match(r"^[0-9a-f]{12}$", version): + abort(400, "Invalid language pack version") + + current_version = get_language_pack_version(lang) + pack = get_language_pack(lang) + if current_version is None or pack is None: + return json_error_response( + "Language pack doesn't exist on the server", status=404 + ) + + response = Response( + f"window.__SUPERSET_LANGUAGE_PACK__ = {json.dumps(pack)};", + mimetype="application/javascript; charset=utf-8", + ) + if version == current_version: + # Content-addressed URL: safe to cache forever. + response.cache_control.public = True + response.cache_control.max_age = 31536000 + response.cache_control.immutable = True + else: + # Stale or unknown version (e.g. HTML rendered before an upgrade): + # serve the current pack but keep caches from pinning it under the + # wrong address. + response.cache_control.no_cache = True + return response + @event_logger.log_this @expose("/welcome/") def welcome(self) -> FlaskResponse: diff --git a/tests/unit_tests/translations/__init__.py b/tests/unit_tests/translations/__init__.py new file mode 100644 index 00000000000..1905cdec909 --- /dev/null +++ b/tests/unit_tests/translations/__init__.py @@ -0,0 +1,17 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from typing import Any diff --git a/tests/unit_tests/translations/utils_test.py b/tests/unit_tests/translations/utils_test.py new file mode 100644 index 00000000000..4459609fe89 --- /dev/null +++ b/tests/unit_tests/translations/utils_test.py @@ -0,0 +1,70 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +import hashlib + +import pytest + +from superset.translations import utils as translations_utils +from superset.translations.utils import ( + get_language_pack_filename, + get_language_pack_version, +) + + +@pytest.fixture(autouse=True) +def _clear_version_cache() -> None: + translations_utils.ALL_LANGUAGE_PACK_VERSIONS.clear() + yield + translations_utils.ALL_LANGUAGE_PACK_VERSIONS.clear() + + +def test_language_pack_filename_resolution() -> None: + assert get_language_pack_filename("fr").endswith("/fr/LC_MESSAGES/messages.json") + assert get_language_pack_filename("en").endswith("/empty_language_pack.json") + assert get_language_pack_filename("").endswith("/empty_language_pack.json") + + +def test_version_is_short_content_hash(tmp_path, monkeypatch) -> None: + pack_file = tmp_path / "fr" / "LC_MESSAGES" / "messages.json" + pack_file.parent.mkdir(parents=True) + pack_file.write_bytes(b'{"domain": "superset"}') + monkeypatch.setattr(translations_utils, "DIR", str(tmp_path)) + + version = get_language_pack_version("fr") + + expected = hashlib.sha256(b'{"domain": "superset"}').hexdigest()[:12] + assert version == expected + + +def test_version_is_cached_and_changes_with_content(tmp_path, monkeypatch) -> None: + pack_file = tmp_path / "fr" / "LC_MESSAGES" / "messages.json" + pack_file.parent.mkdir(parents=True) + pack_file.write_bytes(b"{}") + monkeypatch.setattr(translations_utils, "DIR", str(tmp_path)) + + first = get_language_pack_version("fr") + pack_file.write_bytes(b'{"changed": true}') + # Cached within the process lifetime: same version until cache cleared. + assert get_language_pack_version("fr") == first + + translations_utils.ALL_LANGUAGE_PACK_VERSIONS.clear() + assert get_language_pack_version("fr") != first + + +def test_version_none_when_pack_missing(tmp_path, monkeypatch) -> None: + monkeypatch.setattr(translations_utils, "DIR", str(tmp_path)) + assert get_language_pack_version("xx") is None diff --git a/tests/unit_tests/views/test_bootstrap_auth.py b/tests/unit_tests/views/test_bootstrap_auth.py index a4f2ffe1600..fa468976954 100644 --- a/tests/unit_tests/views/test_bootstrap_auth.py +++ b/tests/unit_tests/views/test_bootstrap_auth.py @@ -135,71 +135,110 @@ def test_recaptcha_shown_for_non_federated_auth( assert payload["conf"]["RECAPTCHA_PUBLIC_KEY"] == "test-key" -# --- language_pack injection -------------------------------------------- +# --- language pack delivery ---------------------------------------------- # -# The Jed pack is injected by `common_bootstrap_payload` (outside the -# memoized `cached_common_bootstrap_data`) using the shared -# `superset.translations.utils.get_language_pack`. Tests here cover the -# wrapper to confirm the pack lands on the payload for non-English -# locales and is None for English. +# The pack is NOT embedded in the bootstrap payload; spa.html loads it via a +# content-addressed script tag whose URL comes from +# `get_language_pack_template_context`. A pack supplied through +# COMMON_BOOTSTRAP_OVERRIDES_FUNC still rides the payload and suppresses the +# script tag. -def test_common_bootstrap_payload_includes_language_pack_for_non_english( +def test_common_bootstrap_payload_does_not_embed_language_pack( app_context: None, ) -> None: - """common.language_pack carries the shared utility's pack for non-en.""" - fake_pack = {"domain": "superset", "locale_data": {"superset": {}}} + """The payload stays small: no full pack even for non-English locales.""" with ( patch( "superset.views.base.cached_common_bootstrap_data", return_value={"locale": "fr"}, ), + patch("superset.views.base.utils.get_user_id", return_value=1), + patch("superset.views.base.get_locale", return_value="fr"), + ): + payload = common_bootstrap_payload() + + assert payload["language_pack"] is None + + +def test_common_bootstrap_payload_preserves_override_pack( + app_context: None, +) -> None: + """A pack from COMMON_BOOTSTRAP_OVERRIDES_FUNC is passed through.""" + fake_pack = {"domain": "superset", "locale_data": {"superset": {}}} + with ( patch( - "superset.views.base.get_language_pack", - return_value=fake_pack, - ) as mock_get, + "superset.views.base.cached_common_bootstrap_data", + return_value={"locale": "fr", "language_pack": fake_pack}, + ), patch("superset.views.base.utils.get_user_id", return_value=1), patch("superset.views.base.get_locale", return_value="fr"), ): payload = common_bootstrap_payload() assert payload["language_pack"] == fake_pack - mock_get.assert_called_once_with("fr") - - -def test_common_bootstrap_payload_skips_pack_for_english( - app_context: None, -) -> None: - """English short-circuits: pack is None and the utility is not called.""" - with ( - patch( - "superset.views.base.cached_common_bootstrap_data", - return_value={"locale": "en"}, - ), - patch("superset.views.base.get_language_pack") as mock_get, - patch("superset.views.base.utils.get_user_id", return_value=1), - patch("superset.views.base.get_locale", return_value="en"), - ): - payload = common_bootstrap_payload() - - assert payload["language_pack"] is None - mock_get.assert_not_called() def test_common_bootstrap_payload_does_not_mutate_memoized_dict( app_context: None, ) -> None: - """Injecting language_pack must not write back into the memoize cache.""" + """Defaulting language_pack must not write back into the memoize cache.""" cached: dict[str, Any] = {"locale": "fr"} with ( patch( "superset.views.base.cached_common_bootstrap_data", return_value=cached, ), - patch("superset.views.base.get_language_pack", return_value={"x": 1}), patch("superset.views.base.utils.get_user_id", return_value=1), patch("superset.views.base.get_locale", return_value="fr"), ): common_bootstrap_payload() assert "language_pack" not in cached + + +def _language_pack_context(locale: str, payload_extra: dict[str, Any]) -> Any: + from superset.views.base import get_language_pack_template_context + + with ( + patch( + "superset.views.base.cached_common_bootstrap_data", + return_value={"locale": locale, **payload_extra}, + ), + patch("superset.views.base.utils.get_user_id", return_value=1), + patch("superset.views.base.get_locale", return_value=locale), + patch( + "superset.views.base.get_language_pack_version", + return_value="abc123def456", + ), + patch( + "superset.views.base.url_for", + return_value="/language_pack/fr/abc123def456/script.js", + ), + ): + return get_language_pack_template_context() + + +def test_language_pack_template_context_versioned_src_for_non_english( + app_context: None, +) -> None: + context = _language_pack_context("fr", {}) + assert context == { + "language_pack_src": "/language_pack/fr/abc123def456/script.js", + "language_pack_inline": False, + } + + +def test_language_pack_template_context_none_for_english( + app_context: None, +) -> None: + context = _language_pack_context("en", {}) + assert context == {"language_pack_src": None, "language_pack_inline": False} + + +def test_language_pack_template_context_inline_for_override_pack( + app_context: None, +) -> None: + """An operator-supplied pack suppresses the script tag; spa.html inlines.""" + context = _language_pack_context("fr", {"language_pack": {"domain": "superset"}}) + assert context == {"language_pack_src": None, "language_pack_inline": True} diff --git a/tests/unit_tests/views/test_language_pack_script.py b/tests/unit_tests/views/test_language_pack_script.py new file mode 100644 index 00000000000..514bfdb3720 --- /dev/null +++ b/tests/unit_tests/views/test_language_pack_script.py @@ -0,0 +1,77 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from typing import Any +from unittest.mock import patch + +FAKE_PACK = {"domain": "superset", "locale_data": {"superset": {"": {}}}} +FAKE_VERSION = "abc123def456" + + +def test_script_served_immutable_when_version_matches(client: Any) -> None: + with ( + patch( + "superset.views.core.get_language_pack_version", + return_value=FAKE_VERSION, + ), + patch("superset.views.core.get_language_pack", return_value=FAKE_PACK), + ): + response = client.get(f"/language_pack/fr/{FAKE_VERSION}/script.js") + + assert response.status_code == 200 + assert response.mimetype == "application/javascript" + body = response.get_data(as_text=True) + assert body.startswith("window.__SUPERSET_LANGUAGE_PACK__ = ") + assert '"domain": "superset"' in body + cache_control = response.headers["Cache-Control"] + assert "immutable" in cache_control + assert "max-age=31536000" in cache_control + assert "public" in cache_control + + +def test_script_not_cacheable_when_version_stale(client: Any) -> None: + """A pre-upgrade HTML page may reference an old version: serve fresh + content, but do not let caches pin it under the stale address.""" + with ( + patch( + "superset.views.core.get_language_pack_version", + return_value=FAKE_VERSION, + ), + patch("superset.views.core.get_language_pack", return_value=FAKE_PACK), + ): + response = client.get("/language_pack/fr/000000000000/script.js") + + assert response.status_code == 200 + assert "no-cache" in response.headers["Cache-Control"] + assert "immutable" not in response.headers["Cache-Control"] + + +def test_script_404_when_pack_missing(client: Any) -> None: + with patch( + "superset.views.core.get_language_pack_version", + return_value=None, + ): + response = client.get(f"/language_pack/xx/{FAKE_VERSION}/script.js") + + assert response.status_code == 404 + + +def test_script_rejects_malformed_lang_and_version(client: Any) -> None: + assert client.get(f"/language_pack/../{FAKE_VERSION}/script.js").status_code in ( + 400, + 404, + ) + assert client.get("/language_pack/fr/not-a-hash!/script.js").status_code == 400