diff --git a/backend/bot/app/web/admin_api_impl/themes.py b/backend/bot/app/web/admin_api_impl/themes.py index b173497..8fd94c1 100644 --- a/backend/bot/app/web/admin_api_impl/themes.py +++ b/backend/bot/app/web/admin_api_impl/themes.py @@ -37,6 +37,46 @@ WEBAPP_LOGO_UPLOAD_CONTENT_TYPES = { } +def _theme_payload_for_version_compare(theme: Any) -> Dict[str, Any]: + if hasattr(theme, "model_dump"): + data = theme.model_dump(mode="json", exclude_none=True) + elif isinstance(theme, dict): + data = dict(theme) + else: + data = {} + data.pop("assets_version", None) + data.pop("default", None) + return data + + +def _bump_theme_asset_versions( + config: WebappThemesConfig, + previous: WebappThemesConfig, +) -> WebappThemesConfig: + previous_by_key = {theme.key: theme for theme in previous.themes} + default_changed = config.default_theme != previous.default_theme + data = config.model_dump(mode="json", exclude_none=True) + for theme in data.get("themes", []): + if not isinstance(theme, dict): + continue + if not str(theme.get("css_file") or "").strip(): + continue + key = str(theme.get("key") or "") + previous_theme = previous_by_key.get(key) + previous_version = int(getattr(previous_theme, "assets_version", 0) or 0) + current_version = int(theme.get("assets_version") or 1) + theme_changed = ( + previous_theme is None + or _theme_payload_for_version_compare(theme) + != _theme_payload_for_version_compare(previous_theme) + ) + if theme_changed or (default_changed and key == config.default_theme): + theme["assets_version"] = max(previous_version + 1, current_version, 1) + elif previous_version > current_version: + theme["assets_version"] = previous_version + return WebappThemesConfig.model_validate(data) + + def _detect_logo_extension( body: bytes, content_type: str = "", filename: str = "" ) -> Optional[str]: @@ -420,6 +460,11 @@ async def admin_themes_get_route(request: web.Request) -> web.Response: async def admin_themes_save_route(request: web.Request) -> web.Response: _require_admin_user_id(request) settings: Settings = request.app["settings"] + previous_config = resolved_webapp_themes_catalog( + primary_accent=settings.WEBAPP_PRIMARY_COLOR or "#00fe7a", + env_default_theme=settings.WEBAPP_DEFAULT_THEME, + theme_dir=settings.WEBAPP_THEMES_DIR, + ) payload = await _read_json(request) catalog = payload.get("catalog") if "catalog" in payload else payload if not isinstance(catalog, dict): @@ -431,6 +476,7 @@ async def admin_themes_save_route(request: web.Request) -> web.Response: return _error(400, "invalid_webapp_themes_config", str(exc)) config, _changed = ensure_webapp_core_themes(config, settings.WEBAPP_PRIMARY_COLOR or "#00fe7a") + config = _bump_theme_asset_versions(config, previous_config) try: write_webapp_theme_dir(settings.WEBAPP_THEMES_DIR, config, delete_missing=True) diff --git a/backend/bot/app/web/webapp/assets.py b/backend/bot/app/web/webapp/assets.py index bd467f7..6cbb4a3 100644 --- a/backend/bot/app/web/webapp/assets.py +++ b/backend/bot/app/web/webapp/assets.py @@ -94,7 +94,10 @@ async def theme_css_asset_route(request: web.Request) -> web.Response: except ValueError: raise web.HTTPNotFound(text="theme_css_not_found") from None - cache_control = "no-cache" + query = getattr(request, "query", {}) or {} + cache_control = ( + "public, max-age=31536000, immutable" if query.get("v") else "no-cache" + ) try: stat = path.stat() if stat.st_size > WEBAPP_THEME_CSS_MAX_BYTES: @@ -1498,7 +1501,14 @@ def _theme_css_href_for_html(theme: Any) -> str: return "" themed_path = "/".join([key, *parts]) encoded = "/".join(quote(part, safe="") for part in themed_path.split("/")) - return f"/webapp-theme-css/{encoded}" if encoded else "" + href = f"/webapp-theme-css/{encoded}" if encoded else "" + try: + version = int(getattr(theme, "assets_version", 0) or 0) + except (TypeError, ValueError): + version = 0 + if href and version > 0: + href = f"{href}?v={quote(str(version), safe='')}" + return href def _initial_theme_for_request(request: web.Request, catalog: Any) -> Any: diff --git a/backend/config/webapp_themes_config.py b/backend/config/webapp_themes_config.py index 4f1c5ec..7f29513 100644 --- a/backend/config/webapp_themes_config.py +++ b/backend/config/webapp_themes_config.py @@ -619,6 +619,7 @@ def public_theme_payload(theme: WebappTheme, primary_accent: str) -> Dict[str, o "enabled": bool(theme.enabled), "use_primary_accent": bool(theme.use_primary_accent), "use_in_admin": bool(theme.use_in_admin), + "assets_version": int(theme.assets_version or 1), "tokens": tokens.model_dump(mode="json", exclude_none=True), } if theme.css_file: diff --git a/frontend/src/lib/webapp/themeStyle.js b/frontend/src/lib/webapp/themeStyle.js index 15fe42d..e10f0ec 100644 --- a/frontend/src/lib/webapp/themeStyle.js +++ b/frontend/src/lib/webapp/themeStyle.js @@ -135,11 +135,20 @@ function encodeThemeCssPath(path) { .join("/"); } +function themeAssetsVersion(theme) { + const version = Number(theme?.assets_version || 0); + return Number.isFinite(version) && version > 0 ? String(Math.floor(version)) : ""; +} + export function themeCssHref(theme) { const cssFile = String(theme?.css_file || "").trim(); if (!cssFile) return ""; if (/^(?:https?:)?\/\//i.test(cssFile) || cssFile.startsWith("data:")) return ""; - if (cssFile.startsWith("/")) return cssFile; + const version = themeAssetsVersion(theme); + if (cssFile.startsWith("/")) { + if (!version) return cssFile; + return `${cssFile}${cssFile.includes("?") ? "&" : "?"}v=${encodeURIComponent(version)}`; + } const normalizedCssFile = cssFile.replace(/\\/g, "/").split("/").filter(Boolean).join("/"); const key = String(theme?.key || "") .trim() @@ -150,7 +159,9 @@ export function themeCssHref(theme) { ? `${key}/${normalizedCssFile}` : normalizedCssFile; const encoded = encodeThemeCssPath(themedPath); - return encoded ? `/webapp-theme-css/${encoded}` : ""; + if (!encoded) return ""; + const href = `/webapp-theme-css/${encoded}`; + return version ? `${href}?v=${encodeURIComponent(version)}` : href; } export function localizedThemeName(theme, lang = "en") { diff --git a/tests/test_webapp_assets.py b/tests/test_webapp_assets.py index f3cc38c..83ab5b9 100644 --- a/tests/test_webapp_assets.py +++ b/tests/test_webapp_assets.py @@ -16,7 +16,7 @@ from bot.app.web import subscription_webapp from bot.app.web.admin_api_impl import themes as admin_themes from bot.app.web.webapp import assets as webapp_assets from config.settings import Settings -from config.webapp_themes_config import builtin_webapp_themes_config +from config.webapp_themes_config import WebappThemesConfig, builtin_webapp_themes_config class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): @@ -281,11 +281,51 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): markup = subscription_webapp._initial_theme_head_markup(request, theme, "#123456") - self.assertIn("/webapp-theme-css/light/style.css", markup) + self.assertIn("/webapp-theme-css/light/style.css?v=", markup) self.assertIn('nonce="nonce-value"', markup) self.assertIn("--accent:#123456", markup) self.assertIn("color-scheme:light", markup) + def test_theme_asset_version_bumps_for_saved_default_css_theme(self): + previous = WebappThemesConfig( + default_theme="dark", + themes=[ + { + "key": "dark", + "default": True, + "tokens": {"color_scheme": "dark"}, + }, + { + "key": "custom", + "default": False, + "css_file": "style.css", + "assets_version": 3, + "tokens": {"color_scheme": "dark"}, + }, + ], + ) + updated = WebappThemesConfig( + default_theme="custom", + themes=[ + { + "key": "dark", + "default": False, + "tokens": {"color_scheme": "dark"}, + }, + { + "key": "custom", + "default": True, + "css_file": "style.css", + "assets_version": 3, + "tokens": {"color_scheme": "dark"}, + }, + ], + ) + + bumped = admin_themes._bump_theme_asset_versions(updated, previous) + + self.assertEqual(bumped.theme_by_key("custom").assets_version, 4) + def test_animated_emoji_asset_path_uses_same_origin_route(self): self.assertEqual( subscription_webapp._webapp_animated_emoji_asset_path("🤩"), @@ -588,6 +628,30 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): self.assertIn("ETag", response.headers) self.assertIn("--bg: red", response.text) + async def test_theme_css_asset_route_uses_immutable_cache_when_versioned(self): + with tempfile.TemporaryDirectory() as tmpdir: + themes_dir = Path(tmpdir) + (themes_dir / "custom").mkdir() + (themes_dir / "custom" / "theme.css").write_text( + ".theme-key-custom { --bg: red; }", encoding="utf-8" + ) + request = SimpleNamespace( + app={ + "settings": SimpleNamespace( + WEBAPP_ENABLED=True, + WEBAPP_THEMES_DIR=str(themes_dir), + ) + }, + match_info={"path": "custom/theme.css"}, + query={"v": "2"}, + ) + + response = await subscription_webapp.theme_css_asset_route(request) + + self.assertEqual( + response.headers["Cache-Control"], "public, max-age=31536000, immutable" + ) + async def test_theme_css_asset_route_returns_not_modified_for_matching_etag(self): with tempfile.TemporaryDirectory() as tmpdir: themes_dir = Path(tmpdir) diff --git a/tests/test_webapp_themes_config.py b/tests/test_webapp_themes_config.py index a686a56..6cc29b1 100644 --- a/tests/test_webapp_themes_config.py +++ b/tests/test_webapp_themes_config.py @@ -273,6 +273,7 @@ class WebappThemesConfigTests(unittest.TestCase): win95 = next(theme for theme in payload["themes"] if theme["key"] == "windows95") self.assertEqual(light["css_file"], "style.css") + self.assertEqual(light["assets_version"], cfg.theme_by_key("light").assets_version) self.assertEqual(light["tokens"]["accent"], "#abc123") self.assertEqual(dark["tokens"]["accent"], "#abc123") self.assertFalse(win95["use_primary_accent"])