refactor: apply custom theme without cache reload

This commit is contained in:
3252a8
2026-05-21 13:56:05 +03:00
parent e7301e2c48
commit 6d4fb5888f
6 changed files with 139 additions and 6 deletions
@@ -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)
+12 -2
View File
@@ -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:
+1
View File
@@ -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:
+13 -2
View File
@@ -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") {
+66 -2
View File
@@ -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)
+1
View File
@@ -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"])